(build): pack a microsoft store msix beside the github setup exe

This commit is contained in:
Maksym Sadovnychyy 2026-09-28 18:37:44 +02:00
parent 7e8a4032e2
commit 7e481262d5
7 changed files with 533 additions and 3 deletions

View File

@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
## [0.8.2] - 2026-09-28
### Added
- Microsoft Store package is a full-trust x64 MSIX (`MAKS-IT.ClusterConsole`). The WiX setup exe remains the GitHub installer. The MSIX is not a GitHub release asset.
## [0.8.1] - 2026-09-27
### Fixed

View File

@ -29,6 +29,54 @@ Coverage shields in `README.md` are rewritten by **CoverageBadges**.
2. Commit on `main`, tag `v{version}` on HEAD (`v1.2.3` or SemVer prerelease such as `v0.1.0-alpha.1`, `v0.1.0-beta.1`, `v0.1.0-rc.1`). GitHub marks hyphenated versions as prerelease.
3. Run `utils\Invoke-ReleasePackage.bat`. That run publishes the portable zip (win-x64), Windows setup exe, and Flatpak (Flatpak via WSL Debian on Windows). Publishing the GitHub Release starts [macOS release assets](.github/workflows/macos-release.yml), which attaches unsigned `osx-arm64` and `osx-x64` DMGs.
## Microsoft Store (MSIX)
`MsixPack` writes `releases/maksit-cluster-console-{version}.msix` from the win-x64 publish. It is a full-trust desktop package (`runFullTrust`), x64, language English. Upload that file on an **MSIX** product in Partner Center. The Store re-signs it. An EXE/MSI product listing cannot take this file.
Partner Center package identity for this product:
| Field | Value |
|-------|--------|
| Package/Identity/Name | `MAKS-IT.ClusterConsole` |
| Package/Identity/Publisher | `CN=FCC8C0E7-6D5F-4028-B8EE-903B88C0C8F9` |
| PublisherDisplayName | `MAKS-IT` |
| Package Family Name | `MAKS-IT.ClusterConsole_pt3s39h1tn26a` |
| Store ID | `9MX86PTHBNN4` |
Those name, publisher, and publisher display strings are `packageName`, `publisher`, and `publisherDisplayName` in `utils/engines/release/scriptSettings.json`. A placeholder publisher `CN=PartnerCenter` stops `MsixPack` until it is replaced.
The `.msix` is not a GitHub release asset.
### System requirements (Properties)
Partner Center → **Properties** → **System requirements**. A blank cell stays unset. Minimum is what the Store may warn on; Recommended does not warn.
The app is a win-x64 desktop console (tables, YAML, terminal). It does not use a camera, microphone, radio, gamepad, or a specific GPU.
| Feature | Minimum | Recommended |
|---------|---------|-------------|
| Touch screen | | |
| Keyboard | Minimum | |
| Mouse | Minimum | |
| Camera | | |
| NFC HCE | | |
| NFC Proximity | | |
| Bluetooth LE | | |
| Telephony | | |
| Microphone | | |
| Xbox controller or gamepad | | |
| Windows Mixed Reality motion controllers | | |
| Windows Mixed Reality immersive headset | | |
| Memory | 2 GB | 4 GB |
| DirectX | Not specified | Not specified |
| Video memory | Not specified | Not specified |
| Processor | x64 | Not specified |
| Graphics | Not specified | Not specified |
## GitHub setup exe
The GitHub Windows installer stays the WiX Burn `setup.exe`. Its switches are `/quiet /norestart` (install), `/repair /quiet /norestart` (repair), and `/uninstall /quiet /norestart` (uninstall). Signing that exe for an EXE/MSI Store listing needs a Trusted Root Authenticode certificate (homelab `works/repostories-maintenance.md`, §11 **Code signing certificate**) and the Burn order: payload PEs, then the MSI, then `wix burn detach` / sign the engine / `wix burn reattach` / sign `setup.exe` ([WiX signing](https://docs.firegiant.com/wix/tools/signing/)).
## Commit format
```text

View File

@ -3,7 +3,7 @@
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<Version>0.8.1</Version>
<Version>0.8.2</Version>
<Product>MaksIT.ClusterConsole</Product>
<AssemblyTitle>MaksIT.ClusterConsole</AssemblyTitle>
</PropertyGroup>

View File

@ -64,6 +64,19 @@
"iconPath": "..\\..\\..\\src\\MaksIT.ClusterConsole.UI\\Assets\\icon.ico",
"exeNamePattern": "maksit-cluster-console-{version}.exe"
},
{
"name": "MsixPack",
"stageLabel": "build",
"enabled": true,
"packageName": "MAKS-IT.ClusterConsole",
"publisher": "CN=FCC8C0E7-6D5F-4028-B8EE-903B88C0C8F9",
"publisherDisplayName": "MaksIT",
"displayName": "MaksIT Cluster Console",
"executableName": "MaksIT.ClusterConsole.UI.exe",
"runtimeIdentifier": "win-x64",
"iconPath": "..\\..\\..\\src\\MaksIT.ClusterConsole.UI\\Assets\\icon.png",
"msixNamePattern": "maksit-cluster-console-{version}.msix"
},
{
"name": "FlatpakPack",
"stageLabel": "build",
@ -129,7 +142,8 @@
"excludePatterns": [
"*.zip",
"*.exe",
"*.flatpak"
"*.flatpak",
"*.msix"
]
}
],

View File

@ -27,6 +27,10 @@ function Clear-ExternalCommandTestHandler {
$script:ExternalCommandTestHandler = $null
}
function Test-ExternalCommandTestHandler {
return ($null -ne $script:ExternalCommandTestHandler)
}
function Set-ExternalCommandAvailability {
param(
[Parameter(Mandatory = $true)]
@ -128,4 +132,5 @@ Export-ModuleMember -Function `
Invoke-ExternalCommand, `
Set-ExternalCommandTestHandler, `
Clear-ExternalCommandTestHandler, `
Test-ExternalCommandTestHandler, `
Set-ExternalCommandAvailability

View File

@ -3,7 +3,7 @@
<#
.SYNOPSIS
Helpers for Community desktop pack plugins (Windows MSI, Linux Flatpak).
Helpers for Community desktop pack plugins (Windows MSI, Store MSIX, Linux Flatpak).
#>
function ConvertTo-WixIdentifier {
@ -1290,6 +1290,184 @@ function New-WixBundleXml {
"@
}
function ConvertTo-MsixPackageVersion {
param(
[Parameter(Mandatory = $true)]
[string]$Version
)
$trimmed = $Version.Trim()
if ($trimmed -match '-') {
throw "MsixPack version '$Version' cannot include a prerelease label. MSIX Identity Version is four numbers (X.Y.Z.0)."
}
$parts = @($trimmed.Split('.', [System.StringSplitOptions]::RemoveEmptyEntries))
if ($parts.Count -lt 1 -or $parts.Count -gt 4) {
throw "MsixPack version '$Version' must be one to four numeric fields."
}
$numbers = [System.Collections.Generic.List[int]]::new()
foreach ($part in $parts) {
$number = 0
if (-not [int]::TryParse($part, [ref]$number) -or $number -lt 0 -or $number -gt 65535) {
throw "MsixPack version '$Version' has a field outside 0..65535."
}
$numbers.Add($number)
}
while ($numbers.Count -lt 4) {
$numbers.Add(0)
}
return ($numbers -join '.')
}
function Assert-MsixPackageName {
param(
[Parameter(Mandatory = $true)]
[string]$PackageName
)
if ($PackageName.Length -lt 3 -or $PackageName.Length -gt 50) {
throw "MsixPack packageName must be 3 to 50 characters: $PackageName"
}
if ($PackageName -notmatch '^[A-Za-z0-9][A-Za-z0-9\.\-]*[A-Za-z0-9]$' -or $PackageName.Contains('..')) {
throw "MsixPack packageName must be letters, digits, hyphens, and single dots: $PackageName"
}
}
function New-MsixManifestXml {
param(
[Parameter(Mandatory = $true)]
[string]$PackageName,
[Parameter(Mandatory = $true)]
[string]$Publisher,
[Parameter(Mandatory = $true)]
[string]$PackageVersion,
[Parameter(Mandatory = $true)]
[string]$ProcessorArchitecture,
[Parameter(Mandatory = $true)]
[string]$DisplayName,
[Parameter(Mandatory = $true)]
[string]$PublisherDisplayName,
[Parameter(Mandatory = $true)]
[string]$ExecutableName,
[Parameter(Mandatory = $false)]
[string]$Description,
[Parameter(Mandatory = $false)]
[string]$Language = 'en-us'
)
$escape = {
param([string]$Value)
return [System.Security.SecurityElement]::Escape($Value)
}
$safeDescription = if ([string]::IsNullOrWhiteSpace($Description)) { $DisplayName } else { $Description }
$exe = [System.IO.Path]::GetFileName($ExecutableName)
return @"
<?xml version="1.0" encoding="utf-8"?>
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" IgnorableNamespaces="uap rescap">
<Identity Name="$(& $escape $PackageName)" Publisher="$(& $escape $Publisher)" Version="$(& $escape $PackageVersion)" ProcessorArchitecture="$(& $escape $ProcessorArchitecture)" />
<Properties>
<DisplayName>$(& $escape $DisplayName)</DisplayName>
<PublisherDisplayName>$(& $escape $PublisherDisplayName)</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.26100.0" />
</Dependencies>
<Resources>
<Resource Language="$(& $escape $Language)" />
</Resources>
<Applications>
<Application Id="App" Executable="$(& $escape $exe)" EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements DisplayName="$(& $escape $DisplayName)" Description="$(& $escape $safeDescription)" BackgroundColor="transparent" Square150x150Logo="Assets\Square150x150Logo.png" Square44x44Logo="Assets\Square44x44Logo.png" />
</Application>
</Applications>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>
"@
}
function Copy-MsixPackageLogos {
param(
[Parameter(Mandatory = $true)]
[string]$IconPath,
[Parameter(Mandatory = $true)]
[string]$AssetsDirectory
)
if (-not (Test-Path -LiteralPath $IconPath -PathType Leaf)) {
throw "MsixPack iconPath not found: $IconPath"
}
New-Item -ItemType Directory -Path $AssetsDirectory -Force | Out-Null
$sizes = [ordered]@{
'Square44x44Logo.png' = 44
'StoreLogo.png' = 50
'Square150x150Logo.png' = 150
}
$drew = $false
try {
Add-Type -AssemblyName System.Drawing -ErrorAction Stop
$image = [System.Drawing.Image]::FromFile((Resolve-Path -LiteralPath $IconPath).Path)
try {
foreach ($name in @($sizes.Keys)) {
$size = [int]$sizes[$name]
$bitmap = New-Object System.Drawing.Bitmap $size, $size
try {
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
try {
$graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$graphics.Clear([System.Drawing.Color]::Transparent)
$graphics.DrawImage($image, 0, 0, $size, $size)
}
finally {
$graphics.Dispose()
}
$bitmap.Save((Join-Path $AssetsDirectory $name), [System.Drawing.Imaging.ImageFormat]::Png)
}
finally {
$bitmap.Dispose()
}
}
$drew = $true
}
finally {
$image.Dispose()
}
}
catch {
$drew = $false
}
if ($drew) {
return
}
foreach ($name in @($sizes.Keys)) {
Copy-Item -LiteralPath $IconPath -Destination (Join-Path $AssetsDirectory $name) -Force
}
}
Export-ModuleMember -Function `
ConvertTo-WixIdentifier, `
Get-MsiProductVersion, `
@ -1300,6 +1478,10 @@ Export-ModuleMember -Function `
Resolve-DesktopPublishDirectory, `
Resolve-DesktopExecutablePath, `
New-WixPackageXml, `
ConvertTo-MsixPackageVersion, `
Assert-MsixPackageName, `
New-MsixManifestXml, `
Copy-MsixPackageLogos, `
Get-DerivedBundleUpgradeCode, `
New-WixBundleXml, `
Get-DefaultFlatpakFinishArgs, `

View File

@ -0,0 +1,275 @@
#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 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')
$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