diff --git a/Desktop/docs/windows-update-feed.md b/Desktop/docs/windows-update-feed.md index 720b85c6..57cff1fc 100644 --- a/Desktop/docs/windows-update-feed.md +++ b/Desktop/docs/windows-update-feed.md @@ -36,11 +36,29 @@ feed with a concrete version: ``` -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` diff --git a/Desktop/scripts/validate-desktop-update-feed.ps1 b/Desktop/scripts/validate-desktop-update-feed.ps1 index 213f8c8d..d4ff699a 100644 --- a/Desktop/scripts/validate-desktop-update-feed.ps1 +++ b/Desktop/scripts/validate-desktop-update-feed.ps1 @@ -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" diff --git a/PRODUCTION_READINESS.md b/PRODUCTION_READINESS.md index da222c58..9df90fa8 100644 --- a/PRODUCTION_READINESS.md +++ b/PRODUCTION_READINESS.md @@ -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 ``, ``, and HTTPS `.msi` ``. +- 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 ``, ``, and HTTPS `.msi` ``. ## Remaining P1 diff --git a/scripts/publish-windows-desktop.ps1 b/scripts/publish-windows-desktop.ps1 new file mode 100644 index 00000000..faf256c0 --- /dev/null +++ b/scripts/publish-windows-desktop.ps1 @@ -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 = @" + + + $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."