Add safe Windows desktop feed publication

This commit is contained in:
Курнат Андрей
2026-06-10 06:43:26 +03:00
parent 0ee974fa38
commit 0961491d52
4 changed files with 254 additions and 25 deletions
+21 -3
View File
@@ -36,11 +36,29 @@ feed with a concrete version:
</QSfera>
```
Validate the live feed before a Windows production release:
From the repository root, prepare the feed XML from a signed MSI and optionally
upload the MSI/feed to the web host:
```powershell
./scripts/validate-desktop-update-feed.ps1 -Url "https://qsfera.kusoft.xyz/desktop/updates/stable.xml"
./scripts/validate-production-release.ps1 -VerifyUpdateFeed
.\scripts\package-windows-desktop.ps1
.\scripts\publish-windows-desktop.ps1 -MsiPath ".\artifacts\windows-desktop\QSfera-Desktop-4.0.0-win-x64.msi"
.\scripts\publish-windows-desktop.ps1 `
-MsiPath ".\artifacts\windows-desktop\QSfera-Desktop-4.0.0-win-x64.msi" `
-RemoteHost "192.168.0.185" `
-RemoteDownloadDirectory "/var/www/qsfera/desktop/downloads" `
-RemoteFeedPath "/var/www/qsfera/desktop/updates/stable.xml"
```
`publish-windows-desktop.ps1` refuses remote publication when the MSI is not
Authenticode-signed. `-SkipSignatureCheck` is only for local feed validation and
is rejected when `-RemoteHost` is set.
From the repository root, validate the feed before a Windows production release:
```powershell
.\Desktop\scripts\validate-desktop-update-feed.ps1 -Url "https://qsfera.kusoft.xyz/desktop/updates/stable.xml"
.\Desktop\scripts\validate-desktop-update-feed.ps1 -Path ".\artifacts\windows-desktop\publish\stable.xml"
.\Desktop\scripts\validate-production-release.ps1 -VerifyUpdateFeed
```
For a stable signed tag release, `validate-production-release.ps1 -RequireSigning`
@@ -1,6 +1,10 @@
[CmdletBinding(DefaultParameterSetName = "Url")]
param(
[Parameter(Mandatory = $true)]
[string]$Url
[Parameter(Mandatory = $true, ParameterSetName = "Url")]
[string]$Url,
[Parameter(Mandatory = $true, ParameterSetName = "Path")]
[string]$Path
)
$ErrorActionPreference = "Stop"
@@ -10,27 +14,42 @@ function Fail($Message) {
exit 1
}
$parsedUrl = $null
if (-not [System.Uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref]$parsedUrl)) {
Fail "Desktop update feed URL is not absolute: $Url"
}
$feedSource = $null
$feedContent = $null
if ($parsedUrl.Scheme -ne "https") {
Fail "Desktop update feed URL must use HTTPS: $Url"
if ($PSCmdlet.ParameterSetName -eq "Url") {
$parsedUrl = $null
if (-not [System.Uri]::TryCreate($Url, [System.UriKind]::Absolute, [ref]$parsedUrl)) {
Fail "Desktop update feed URL is not absolute: $Url"
}
if ($parsedUrl.Scheme -ne "https") {
Fail "Desktop update feed URL must use HTTPS: $Url"
}
try {
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 30
} catch {
Fail "Desktop update feed request failed for ${Url}: $($_.Exception.Message)"
}
if ($response.StatusCode -ne 200) {
Fail "Desktop update feed must return HTTP 200. Actual status: $($response.StatusCode)"
}
$feedSource = $Url
$feedContent = $response.Content
} else {
if (-not (Test-Path -LiteralPath $Path)) {
Fail "Desktop update feed file was not found: $Path"
}
$feedSource = (Resolve-Path -LiteralPath $Path).Path
$feedContent = Get-Content -LiteralPath $feedSource -Raw
}
try {
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 30
} catch {
Fail "Desktop update feed request failed for ${Url}: $($_.Exception.Message)"
}
if ($response.StatusCode -ne 200) {
Fail "Desktop update feed must return HTTP 200. Actual status: $($response.StatusCode)"
}
try {
[xml]$xml = $response.Content
[xml]$xml = $feedContent
} catch {
Fail "Desktop update feed is not valid XML: $($_.Exception.Message)"
}
@@ -82,4 +101,4 @@ if ([string]::IsNullOrWhiteSpace($version)) {
}
}
Write-Host "QSfera Desktop update feed validation passed: $Url"
Write-Host "QSfera Desktop update feed validation passed: $feedSource"
+4 -2
View File
@@ -41,6 +41,8 @@ Date: 2026-06-08.
- Windows/Desktop CI now validates production release invariants before Windows packaging and requires signing variables for stable tag releases.
- Windows/Desktop now has a configured HTTPS update feed at `https://qsfera.kusoft.xyz/desktop/updates/stable.xml`; the feed returned HTTP `200` on 2026-06-08 and contains the XML elements parsed by `Desktop/src/gui/updater/updateinfo.cpp`.
- Windows local autostart was repaired on 2026-06-10 with `scripts/repair-windows-autostart.ps1 -Launch`: the HKCU Run key `QSfera` points to the Craft runtime `bin\qsfera.exe`, required Qt6 runtime DLLs are present there, and `qsfera.exe` started from that path.
- Windows tray "Show" now brings the settings window to the foreground after restoring it to an available screen. The same `Application::showSettings()` path is used by tray menu actions and `qsfera.exe --show`, and Win32 window enumeration on 2026-06-10 reported `Visible=True`, `Foreground=True`, title `КуСфера`.
- Windows Desktop now has repeatable local packaging and feed-publication scripts. `scripts/package-windows-desktop.ps1` produced a smoke-tested `4.0.0` x64 package with Qt/MSVC runtime files; `scripts/publish-windows-desktop.ps1` generated a valid local `stable.xml` feed for that MSI and refuses remote publication unless the MSI is Authenticode-signed.
- Added `scripts/verify-production-state.ps1` as a repeatable live production endpoint gate for the public server status, Android Argus manifest/download, and Desktop update feed.
- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-10: server `edition=stable`/`productversion=7.0.0`, Android `1.3.9`/`37`, downloaded APK size `12226572`, APK SHA-256 `cbd2c25328e395ea4da4e0f582feac39dec18d2d47b3e105d344904d8a24f017`, and valid Desktop no-update feed.
- Added `Server/scripts/verify-rpi-production.sh`, declared a loopback-only Pi metrics port in `Server/docker-compose.rpi5.yml`, and expanded the Raspberry Pi Docker runbook with local/public status checks, container/image checks, Prometheus metrics verification, backup/restore drill steps, and Docker data-root/retention planning.
@@ -76,8 +78,8 @@ Date: 2026-06-08.
- Decide the rollout policy for any devices that installed the debug-signed Argus `1.3.5` APK. The production-signed `1.3.9` APK is the correct current release, but installed debug-signed clients will not satisfy the app's own signer-digest check for an in-place update.
- Fill production domains, ACME email, admin passwords, Keycloak passwords, and storage secrets from a secret manager. Do not commit real values.
- Run `./validate-production-env.sh .env` and then `docker compose config` for `Server/devtools/deployments/qsfera_full` with a filled production `.env`; Docker CLI is not available in this local environment, so compose rendering is not locally verified.
- Provide real Windows signing secrets in CI: `QSFERA_DESKTOP_SIGN_PACKAGE=True` and `QSFERA_DESKTOP_CODESIGN_CERTIFICATE` with the Craft-compatible signing certificate reference.
- Publish the first signed Windows MSI and update `https://qsfera.kusoft.xyz/desktop/updates/stable.xml` from the current no-update XML to a concrete `<version>`, `<versionstring>`, and HTTPS `.msi` `<downloadurl>`.
- Provide real Windows signing secrets in CI: `QSFERA_DESKTOP_SIGN_PACKAGE=True` and `QSFERA_DESKTOP_CODESIGN_CERTIFICATE` with the Craft-compatible signing certificate reference. The current local MSI is intentionally not published as a production update because `Get-AuthenticodeSignature` reports `NotSigned` and `Desktop/scripts/validate-production-release.ps1 -RequireSigning` fails until signing is configured.
- Publish the first signed Windows MSI with `scripts/publish-windows-desktop.ps1 -RemoteHost ...` and update `https://qsfera.kusoft.xyz/desktop/updates/stable.xml` from the current no-update XML to a concrete `<version>`, `<versionstring>`, and HTTPS `.msi` `<downloadurl>`.
## Remaining P1
+190
View File
@@ -0,0 +1,190 @@
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 = @"
<?xml version="1.0" encoding="UTF-8"?>
<QSfera>
<version>$Version</version>
<versionstring>$Version</versionstring>
<web>$publicBase/</web>
<downloadurl>$downloadUrl</downloadurl>
</QSfera>
"@
$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."