diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a3a2e..7c3b7a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 817590e..4e6fb15 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/src/Directory.Build.props b/src/Directory.Build.props index a8bfa53..87bc9ad 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -3,7 +3,7 @@ latest enable enable - 0.8.1 + 0.8.2 MaksIT.ClusterConsole MaksIT.ClusterConsole diff --git a/utils/engines/release/scriptSettings.json b/utils/engines/release/scriptSettings.json index 2b82c55..73d1001 100644 --- a/utils/engines/release/scriptSettings.json +++ b/utils/engines/release/scriptSettings.json @@ -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" ] } ], diff --git a/utils/modules/ExternalCommandSupport.psm1 b/utils/modules/ExternalCommandSupport.psm1 index f8420ef..5574f02 100644 --- a/utils/modules/ExternalCommandSupport.psm1 +++ b/utils/modules/ExternalCommandSupport.psm1 @@ -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 diff --git a/utils/plugins/Desktop/DesktopPackSupport.psm1 b/utils/plugins/Desktop/DesktopPackSupport.psm1 index 4f7bf9d..ce45b84 100644 --- a/utils/plugins/Desktop/DesktopPackSupport.psm1 +++ b/utils/plugins/Desktop/DesktopPackSupport.psm1 @@ -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 @" + + + + + $(& $escape $DisplayName) + $(& $escape $PublisherDisplayName) + Assets\StoreLogo.png + + + + + + + + + + + + + + + + +"@ +} + +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, ` diff --git a/utils/plugins/Desktop/MsixPack.psm1 b/utils/plugins/Desktop/MsixPack.psm1 new file mode 100644 index 0000000..f7562e9 --- /dev/null +++ b/utils/plugins/Desktop/MsixPack.psm1 @@ -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\\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