param( [string]$MsiPath = (Join-Path $PSScriptRoot "..\artifacts\windows-desktop\QSfera-Desktop-4.0.0-win-x64.msi"), [string]$Version, [string]$Channel = "stable", [string]$PublicBaseUrl = "https://qsfera.kusoft.xyz", [string]$OutputRoot = (Join-Path $PSScriptRoot "..\artifacts\windows-desktop\publish"), [string]$RemoteHost, [string]$RemoteUser = "sevenhill", [int]$RemotePort = 22, [string]$RemoteDownloadDirectory = "/var/www/qsfera/desktop/downloads", [string]$RemoteFeedPath = "/var/www/qsfera/desktop/updates/stable.xml", [switch]$SkipSignatureCheck ) $ErrorActionPreference = "Stop" function Fail([string]$Message) { Write-Error $Message exit 1 } function Assert-HttpsBaseUrl([string]$Url) { $uri = $null if (-not [System.Uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -ne "https") { Fail "PublicBaseUrl must be an absolute HTTPS URL: $Url" } return $uri.AbsoluteUri.TrimEnd("/") } function Get-QSferaDesktopVersion { $versionFile = Join-Path $PSScriptRoot "..\Desktop\VERSION.cmake" if (-not (Test-Path -LiteralPath $versionFile)) { Fail "Desktop version file was not found: $versionFile" } $values = @{} foreach ($line in Get-Content -LiteralPath $versionFile) { if ($line -match '^\s*set\(\s*(MIRALL_VERSION_(MAJOR|MINOR|PATCH|BUILD))\s+"?([^"\s\)]+)"?\s*\)') { $values[$matches[1]] = $matches[3] } } foreach ($key in @("MIRALL_VERSION_MAJOR", "MIRALL_VERSION_MINOR", "MIRALL_VERSION_PATCH")) { if (-not $values.ContainsKey($key)) { Fail "Desktop version file does not define $key." } } $baseVersion = "{0}.{1}.{2}" -f $values["MIRALL_VERSION_MAJOR"], $values["MIRALL_VERSION_MINOR"], $values["MIRALL_VERSION_PATCH"] $build = $values["MIRALL_VERSION_BUILD"] if ([string]::IsNullOrWhiteSpace($build) -or $build -eq "0") { return $baseVersion } return "$baseVersion.$build" } function Quote-RemotePath([string]$Path) { return "'" + ($Path -replace "'", "'\''") + "'" } function Get-RemoteParentPath([string]$Path) { $index = $Path.LastIndexOf("/") if ($index -le 0) { return "/" } return $Path.Substring(0, $index) } function Invoke-CheckedNative([string]$Program, [string[]]$Arguments) { & $Program @Arguments if ($LASTEXITCODE -ne 0) { Fail "$Program failed with exit code $LASTEXITCODE." } } $resolvedMsi = $null try { $resolvedMsi = (Resolve-Path -LiteralPath $MsiPath).Path } catch { Fail "MSI file was not found: $MsiPath" } if (-not $resolvedMsi.EndsWith(".msi", [System.StringComparison]::OrdinalIgnoreCase)) { Fail "Windows desktop publication expects an .msi file: $resolvedMsi" } $hasRemoteTarget = -not [string]::IsNullOrWhiteSpace($RemoteHost) if ($hasRemoteTarget -and $SkipSignatureCheck) { Fail "Remote Windows desktop publication cannot use -SkipSignatureCheck." } $signature = Get-AuthenticodeSignature -LiteralPath $resolvedMsi if (-not $SkipSignatureCheck -and $signature.Status -ne "Valid") { Fail "MSI must be Authenticode-signed before Windows production publication. Actual status: $($signature.Status)." } if ([string]::IsNullOrWhiteSpace($Version)) { $Version = Get-QSferaDesktopVersion } $parsedVersion = $null if (-not [System.Version]::TryParse($Version, [ref]$parsedVersion)) { Fail "Version must be parseable as a Windows updater version: $Version" } if ($Channel -ne "stable") { Fail "Only the stable desktop update feed is configured for production publication. Actual channel: $Channel" } $publicBase = Assert-HttpsBaseUrl $PublicBaseUrl $msiName = Split-Path -Leaf $resolvedMsi $downloadUrl = "$publicBase/desktop/downloads/$msiName" $feedPath = Join-Path (New-Item -ItemType Directory -Path $OutputRoot -Force).FullName "$Channel.xml" $sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $resolvedMsi).Hash.ToLowerInvariant() $size = (Get-Item -LiteralPath $resolvedMsi).Length $feed = @" $Version $Version $publicBase/ $downloadUrl "@ $feed | Set-Content -LiteralPath $feedPath -Encoding UTF8 $feedValidator = Join-Path $PSScriptRoot "..\Desktop\scripts\validate-desktop-update-feed.ps1" if (-not (Test-Path -LiteralPath $feedValidator)) { Fail "Desktop update feed validator was not found: $feedValidator" } & $feedValidator -Path $feedPath if (-not $?) { Fail "Generated desktop update feed failed validation." } Write-Output "QSfera Windows desktop publication prepared." Write-Output "MSI: $resolvedMsi" Write-Output "Version: $Version" Write-Output "Size: $size" Write-Output "SHA-256: $sha256" Write-Output "Feed: $feedPath" Write-Output "Download URL: $downloadUrl" Write-Output "Signature: $($signature.Status)" if (-not $hasRemoteTarget) { Write-Output "Remote upload skipped: set -RemoteHost after signing the MSI." exit 0 } $scp = Get-Command scp -ErrorAction SilentlyContinue $ssh = Get-Command ssh -ErrorAction SilentlyContinue if (-not $scp -or -not $ssh) { Fail "Both scp and ssh must be available for remote Windows desktop publication." } $remote = "$RemoteUser@$RemoteHost" $remoteMsiTmp = "$RemoteDownloadDirectory/$msiName.tmp" $remoteMsiFinal = "$RemoteDownloadDirectory/$msiName" $remoteFeedTmp = "$RemoteFeedPath.tmp" $sshPort = [string]$RemotePort $remoteFeedDirectory = Get-RemoteParentPath $RemoteFeedPath $prepareCommand = @( "set -e", "mkdir -p $(Quote-RemotePath $RemoteDownloadDirectory)", "mkdir -p $(Quote-RemotePath $remoteFeedDirectory)" ) -join " && " Invoke-CheckedNative $ssh.Source @("-p", $sshPort, $remote, $prepareCommand) Invoke-CheckedNative $scp.Source @("-P", $sshPort, $resolvedMsi, "${remote}:$remoteMsiTmp") Invoke-CheckedNative $scp.Source @("-P", $sshPort, $feedPath, "${remote}:$remoteFeedTmp") $remoteCommand = @( "set -e", "test -s $(Quote-RemotePath $remoteMsiTmp)", "test -s $(Quote-RemotePath $remoteFeedTmp)", "mv -f $(Quote-RemotePath $remoteMsiTmp) $(Quote-RemotePath $remoteMsiFinal)", "mv -f $(Quote-RemotePath $remoteFeedTmp) $(Quote-RemotePath $RemoteFeedPath)" ) -join " && " Invoke-CheckedNative $ssh.Source @("-p", $sshPort, $remote, $remoteCommand) & $feedValidator -Url "$publicBase/desktop/updates/$Channel.xml" if (-not $?) { Fail "Live desktop update feed failed validation after upload." } Write-Output "QSfera Windows desktop publication uploaded."