maksit-cluster-console/utils/plugins/Desktop/MsixPack.psm1
Maksym Sadovnychyy ab71023106 (feature): add workload, storage, and access actions, and highlight full YAML
Rollout control, PVC resize, reclaim policy, certificates, tokens, node images, and an optional confirmed chat agent are in this release. YAML coloring now covers the whole document, including long lines.
2026-09-29 21:25:25 +02:00

305 lines
12 KiB
PowerShell

#requires -Version 7.0
#requires -PSEdition Core
<#
.SYNOPSIS
Store MSIX plugin for a published win-* .NET desktop app (Community).
.DESCRIPTION
Packs the win-* DotNetPublish folder into a full-trust .msix and signs it
with a short-lived self-signed certificate whose subject is `publisher`.
The Microsoft Store re-signs the package; this signature is only the upload
envelope. The .msix is not a GitHub release asset and is not added to the
portable zip. Requires makeappx.exe and signtool.exe from the Windows SDK.
#>
if (-not (Get-Command Import-PluginDependency -ErrorAction SilentlyContinue)) {
$srcDir = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$pluginSupportModulePath = Join-Path $srcDir "modules/Engine/PluginSupport.psm1"
if (Test-Path $pluginSupportModulePath -PathType Leaf) {
Import-Module $pluginSupportModulePath -Force -Global -ErrorAction Stop
}
}
function Get-WindowsSdkToolPath {
param(
[Parameter(Mandatory = $true)]
[string]$FileName
)
$roots = @(
${env:ProgramFiles(x86)},
$env:ProgramFiles
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($root in $roots) {
$bin = Join-Path $root 'Windows Kits\10\bin'
if (-not (Test-Path -LiteralPath $bin -PathType Container)) {
continue
}
$versions = @(
Get-ChildItem -LiteralPath $bin -Directory |
Where-Object { $_.Name -match '^10\.' } |
Sort-Object { try { [version]$_.Name } catch { [version]'0.0' } } -Descending
)
foreach ($version in $versions) {
$candidate = Join-Path $version.FullName "x64\$FileName"
if (Test-Path -LiteralPath $candidate -PathType Leaf) {
return $candidate
}
}
}
return $null
}
function Get-MsixToolCommand {
param(
[Parameter(Mandatory = $true)]
[string]$FileName,
[Parameter(Mandatory = $true)]
[string]$StubName
)
if (Test-ExternalCommandTestHandler) {
return $StubName
}
$path = Get-WindowsSdkToolPath -FileName $FileName
if ([string]::IsNullOrWhiteSpace($path)) {
throw "Windows SDK tool '$FileName' was not found. Install the Windows 10 SDK so $FileName is under 'Windows Kits\10\bin\<version>\x64'."
}
return $path
}
function Invoke-MsixSign {
param(
[Parameter(Mandatory = $true)]
[string]$MsixPath,
[Parameter(Mandatory = $true)]
[string]$Publisher,
[Parameter(Mandatory = $true)]
[string]$SignTool
)
$thumb = 'TEST'
$created = $false
try {
if (-not (Test-ExternalCommandTestHandler)) {
$cert = New-SelfSignedCertificate `
-Type Custom `
-Subject $Publisher `
-KeyUsage DigitalSignature `
-KeyAlgorithm RSA `
-KeyLength 2048 `
-FriendlyName 'MaksIT MsixPack ephemeral' `
-CertStoreLocation 'Cert:\CurrentUser\My' `
-TextExtension @('2.5.29.37={text}1.3.6.1.5.5.7.3.3') `
-NotAfter (Get-Date).AddDays(1)
$thumb = $cert.Thumbprint
$created = $true
}
Invoke-ExternalCommand -Name $SignTool -ArgumentList @(
'sign', '/fd', 'SHA256', '/sha1', $thumb,
'/tr', 'http://timestamp.digicert.com', '/td', 'SHA256',
$MsixPath
) | Out-Null
}
finally {
if ($created -and -not [string]::IsNullOrWhiteSpace($thumb)) {
$certPath = Join-Path 'Cert:\CurrentUser\My' $thumb
if (Test-Path -LiteralPath $certPath) {
Remove-Item -LiteralPath $certPath -Force
}
}
}
}
function Remove-MsixSatelliteCultures {
param(
[Parameter(Mandatory = $true)]
[string]$Layout
)
$names = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($culture in [System.Globalization.CultureInfo]::GetCultures([System.Globalization.CultureTypes]::AllCultures)) {
if (-not [string]::IsNullOrWhiteSpace($culture.Name)) {
[void]$names.Add($culture.Name)
}
}
foreach ($dir in @(Get-ChildItem -LiteralPath $Layout -Directory -ErrorAction SilentlyContinue)) {
if (-not $names.Contains($dir.Name)) {
continue
}
$satellites = @(Get-ChildItem -LiteralPath $dir.FullName -Filter '*.resources.dll' -File -ErrorAction SilentlyContinue)
if ($satellites.Count -eq 0) {
continue
}
Write-Log -Level "STEP" -Message "Omitting '$($dir.Name)' satellite resources so the Store package stays English-only."
Remove-Item -LiteralPath $dir.FullName -Recurse -Force
}
}
function Invoke-Plugin {
param(
[Parameter(Mandatory = $true)]
$Settings
)
Import-PluginDependency -ModuleName "Logging" -RequiredCommand "Write-Log"
Import-PluginDependency -ModuleName "EngineContext" -RequiredCommand "Set-EngineFact"
Import-PluginDependency -ModuleName "ExternalCommandSupport" -RequiredCommand "Invoke-ExternalCommand"
Import-PluginDependency -ModuleName "DesktopPackSupport" -RequiredCommand "New-MsixManifestXml"
if (-not $IsWindows -and -not (Test-ExternalCommandTestHandler)) {
throw "MsixPack requires Windows (Windows SDK makeappx.exe and signtool.exe)."
}
$pluginSettings = $Settings
$sharedSettings = $Settings.context
$scriptDir = [string]$sharedSettings.scriptDir
$version = [string]$sharedSettings.version
$artifactsDirectory = [string]$sharedSettings.artifactsDirectory
if ([string]::IsNullOrWhiteSpace($version)) {
throw "MsixPack requires a release version in the shared context (DotNetReleaseVersion)."
}
if ([string]::IsNullOrWhiteSpace($artifactsDirectory)) {
throw "MsixPack requires an artifacts directory in the shared context."
}
$packageName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'packageName')
$publisher = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'publisher')
$executableName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'executableName')
if ([string]::IsNullOrWhiteSpace($packageName)) {
throw "MsixPack requires packageName."
}
if ([string]::IsNullOrWhiteSpace($publisher) -or $publisher -eq 'CN=PartnerCenter') {
throw "MsixPack requires publisher set to the Partner Center package identity (CN=...)."
}
if ($publisher -notmatch '(?i)(^|,)\s*CN=') {
throw "MsixPack publisher must be a distinguished name starting with CN=: $publisher"
}
if ([string]::IsNullOrWhiteSpace($executableName)) {
throw "MsixPack requires executableName."
}
Assert-MsixPackageName -PackageName $packageName
$packageVersion = ConvertTo-MsixPackageVersion -Version $version
$displayName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'displayName' -Default $packageName)
$publisherDisplayName = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'publisherDisplayName' -Default 'MaksIT')
$description = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'description' -Default '')
$language = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'language' -Default 'en-us')
$runtimeIdentifier = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'runtimeIdentifier' -Default 'win-x64')
$architecture = Get-WixArchitectureFromRuntimeIdentifier -RuntimeIdentifier $runtimeIdentifier
$publishDirSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'publishDir')
$iconSetting = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'iconPath')
if (-not (Test-Path -LiteralPath $artifactsDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $artifactsDirectory | Out-Null
}
$publishDirectory = Resolve-DesktopPublishDirectory `
-Context $sharedSettings `
-RuntimeIdentifier $runtimeIdentifier `
-PublishDir $publishDirSetting `
-ScriptDir $scriptDir
$null = Resolve-DesktopExecutablePath `
-PublishDirectory $publishDirectory `
-ExecutableName $executableName `
-Windows
$resolvePackPath = {
param([string]$Setting)
if ([string]::IsNullOrWhiteSpace($Setting)) {
return $null
}
if ([System.IO.Path]::IsPathRooted($Setting)) {
return $Setting
}
return [System.IO.Path]::GetFullPath((Join-Path $scriptDir $Setting))
}
$iconPath = & $resolvePackPath $iconSetting
if ([string]::IsNullOrWhiteSpace($iconPath)) {
$fallback = Join-Path $PSScriptRoot 'brand\mark.png'
if (Test-Path -LiteralPath $fallback -PathType Leaf) {
$iconPath = $fallback
}
}
if ([string]::IsNullOrWhiteSpace($iconPath) -or -not (Test-Path -LiteralPath $iconPath -PathType Leaf)) {
throw "MsixPack iconPath not found: $iconSetting"
}
$safeName = ($packageName -replace '[^A-Za-z0-9._-]', '-').Trim('-')
$namePattern = [string](Get-PluginPropertyValue -PluginSettings $pluginSettings -Name 'msixNamePattern' -Default '{name}-{version}.msix')
$msixFileName = $namePattern.Replace('{version}', $version).Replace('{name}', $safeName)
$msixPath = Join-Path $artifactsDirectory $msixFileName
$stageDir = Join-Path $artifactsDirectory '.msix-stage'
if (Test-Path -LiteralPath $stageDir) {
Remove-Item -LiteralPath $stageDir -Recurse -Force
}
$layout = Join-Path $stageDir 'layout'
New-Item -ItemType Directory -Path $layout | Out-Null
Copy-Item -Path (Join-Path $publishDirectory '*') -Destination $layout -Recurse -Force
$manifest = New-MsixManifestXml `
-PackageName $packageName `
-Publisher $publisher `
-PackageVersion $packageVersion `
-ProcessorArchitecture $architecture `
-DisplayName $displayName `
-PublisherDisplayName $publisherDisplayName `
-ExecutableName $executableName `
-Description $description `
-Language $language
[System.IO.File]::WriteAllText((Join-Path $layout 'AppxManifest.xml'), $manifest, [System.Text.UTF8Encoding]::new($false))
Copy-MsixPackageLogos -IconPath $iconPath -AssetsDirectory (Join-Path $layout 'Assets')
Remove-MsixSatelliteCultures -Layout $layout
$makeAppx = Get-MsixToolCommand -FileName 'makeappx.exe' -StubName 'makeappx'
$signTool = Get-MsixToolCommand -FileName 'signtool.exe' -StubName 'signtool'
if (Test-Path -LiteralPath $msixPath -PathType Leaf) {
Remove-Item -LiteralPath $msixPath -Force
}
Write-Log -Level "STEP" -Message "Packing MSIX for '$displayName' ($architecture, $packageVersion)..."
Invoke-ExternalCommand -Name $makeAppx -ArgumentList @('pack', '/d', $layout, '/p', $msixPath, '/o') | Out-Null
if (-not (Test-Path -LiteralPath $msixPath -PathType Leaf)) {
throw "makeappx completed but MSIX was not produced: $msixPath"
}
Write-Log -Level "STEP" -Message "Signing MSIX with an ephemeral certificate ($publisher)..."
Invoke-MsixSign -MsixPath $msixPath -Publisher $publisher -SignTool $signTool
if (Test-Path -LiteralPath $stageDir) {
Remove-Item -LiteralPath $stageDir -Recurse -Force
}
Set-EngineFact -Context $sharedSettings -Namespace 'desktop' -Name 'msixPath' -Value $msixPath -Overwrite Replace
Write-Log -Level "OK" -Message " Store MSIX ready: $msixPath"
}
Export-ModuleMember -Function Invoke-Plugin