Compare commits

..

12 Commits

Author SHA1 Message Date
Курнат Андрей 39d65fcede Align Keeper Argus publication flow 2026-06-20 19:53:04 +03:00
Курнат Андрей cdd442ebf7 Record Android 1.3.14 Argus publication 2026-06-13 20:07:01 +03:00
Курнат Андрей 0c5f178c8e Bump Android Argus release to 1.3.14 2026-06-13 20:02:56 +03:00
Курнат Андрей 9ad7f0abdb Align Android Argus publication with SSH contract 2026-06-13 19:47:25 +03:00
Курнат Андрей b9a0c26b0b Pin Android Argus release in production check 2026-06-12 23:05:26 +03:00
Курнат Андрей 9fad6b1595 Validate server compose env fragments 2026-06-12 23:02:01 +03:00
Курнат Андрей 709a38cae1 Add PowerShell server production env validation 2026-06-12 22:57:02 +03:00
Курнат Андрей 36f052223b Verify Android release signing continuity 2026-06-12 22:53:46 +03:00
Курнат Андрей 2607c7d08e Verify Windows desktop packaged runtime 2026-06-12 22:49:28 +03:00
Курнат Андрей da19575477 Record Android 1.3.13 Argus publication 2026-06-12 22:43:36 +03:00
Курнат Андрей 688c0a3b19 Bump Android release to 1.3.13 2026-06-12 22:39:51 +03:00
Курнат Андрей 251391b1a9 Require git provenance for Android Argus staging 2026-06-12 22:38:52 +03:00
17 changed files with 1285 additions and 302 deletions
+10 -6
View File
@@ -5,20 +5,24 @@
- Android update requirement: Android Developers says an update must use the same application ID and the same signing certificate as the installed app, unless it contains a valid proof-of-rotation: <https://developer.android.com/google/play/app-updates>.
- QSfera update implementation: `Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/settings/update/AppUpdateManager.kt`.
- QSfera Android version and release signing env names: `Android/qsferaApp/build.gradle`.
- Argus publication authority: `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`.
- Current Argus release and certificate facts verified for this production-readiness pass: `PRODUCTION_READINESS.md`, section `Android Argus Publication Check`.
## Policy
- Stable Argus releases for `qsfera-mobile` must keep the same package name and production signing certificate after the first production-signed `1.3.11` / `39` release.
- The in-app updater must not bypass certificate mismatch. `AppUpdateManager` validates package name, monotonically newer `versionCode`, Argus `androidVersionCode`, and signer digest before starting Android package installation.
- A client installed from the debug-signed `1.3.5` / `33` Argus APK cannot be moved to the current production-signed stable APK (`1.3.12` / `40` as of 2026-06-10) by the in-app updater because the signer digest check intentionally fails.
- Argus publication is SSH-only and must follow `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`: copy the APK to the Pi, write metadata to `/srv/argus-data/argus.db`, and store package bytes under `/srv/argus-data/Packages`. Do not add or use HTTP `/api/admin/*` publication routes from this repository.
- The in-app updater must use the public read-only manifest endpoint, treat HTTP `404` as no update, compare `release.version` with the installed version, download `ARGUS_BASE_URL + release.downloadPath`, verify `release.packageSizeBytes` and `release.sha256`, and only then start Android package installation.
- The in-app updater must not bypass certificate mismatch. `AppUpdateManager` validates package name, newer installed APK metadata, optional Argus `androidVersionCode` when present, manifest version, and signer digest before starting Android package installation.
- A client installed from the debug-signed `1.3.5` / `33` Argus APK cannot be moved to the current production-signed stable APK (`1.3.14` / `42` as of 2026-06-13) by the in-app updater because the signer digest check intentionally fails.
- For debug-signed `1.3.5` installs, the supported migration path is: back up unsynced local files, uninstall the debug-signed build, then install the current stable QSfera APK from Argus.
- On certificate mismatch, the Android app must show an explicit reinstall-required dialog instead of a generic update error.
## Release Checklist
1. Build the stable APK with the same release key material through `QSFERA_RELEASE_KEYSTORE`, `QSFERA_RELEASE_KEYSTORE_PASSWORD`, `QSFERA_RELEASE_KEY_ALIAS`, and `QSFERA_RELEASE_KEY_PASSWORD`.
2. Prepare Argus publication only from a clean release branch whose `HEAD` matches its upstream; `Android/scripts/prepare-argus-publication.ps1` enforces this by default and records branch, upstream, commit, and verification time in `argus-publication.json`.
3. Verify the APK certificate before upload with `apksigner verify --print-certs`.
4. Publish to Argus only after certificate, package name, version code, SHA-256, and git provenance checks pass.
5. Verify the published manifest and download with `scripts/verify-production-state.ps1 -VerifyAndroidDownload` from the repository root.
2. Prepare Argus publication only from a clean release branch whose `HEAD` matches its upstream; `Android/scripts/prepare-argus-publication.ps1` enforces this by default and records repository root, branch, upstream, commit, and verification time in `argus-publication.json`. When the Android checkout is used from the pushed `QSfera.git` monorepo, run it from `Android/` with `-GitProvenanceRoot ..`.
3. Verify the local release signing material and latest Argus APK certificate with `Android/scripts/verify-release-signing-material.ps1 -VerifyPublishedApk`.
4. Verify the APK certificate before upload with `apksigner verify --print-certs`.
5. Publish to Argus only through `Android/scripts/publish-release.ps1`, which implements the SSH/SQLite/Data/Packages workflow from `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`; do not publish through HTTP admin APIs.
6. Verify the published manifest and download with `scripts/verify-production-state.ps1 -VerifyAndroidDownload` from the repository root.
+2 -2
View File
@@ -135,8 +135,8 @@ android {
testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner"
versionCode = 40
versionName = "1.3.12"
versionCode = 42
versionName = "1.3.14"
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
@@ -44,7 +44,7 @@ class AppUpdateManager(private val fragment: Fragment) {
runCatching {
withContext(Dispatchers.IO) { fetchLatestRelease(context) }
}.onSuccess { release ->
if (release.androidVersionCode <= BuildConfig.VERSION_CODE) {
if (release == null || compareVersions(release.version, BuildConfig.VERSION_NAME) <= 0) {
showNoUpdatesDialog(context)
} else {
showUpdateDialog(context, release)
@@ -62,9 +62,7 @@ class AppUpdateManager(private val fragment: Fragment) {
context.getString(
R.string.update_available_message,
release.version,
release.androidVersionCode,
BuildConfig.VERSION_NAME,
BuildConfig.VERSION_CODE
BuildConfig.VERSION_NAME
)
)
.setNegativeButton(android.R.string.cancel, null)
@@ -95,8 +93,13 @@ class AppUpdateManager(private val fragment: Fragment) {
fragment.lifecycleScope.launch {
runCatching {
withContext(Dispatchers.IO) {
downloadApk(context, release).also { apk ->
val apk = downloadApk(context, release)
try {
validateDownloadedApk(context, apk, release)
apk
} catch (error: Throwable) {
apk.delete()
throw error
}
}
}.onSuccess { apk ->
@@ -139,25 +142,45 @@ class AppUpdateManager(private val fragment: Fragment) {
.show()
}
private fun fetchLatestRelease(context: Context): ArgusRelease {
val json = requestText(ARGUS_MANIFEST_URL)
private fun fetchLatestRelease(context: Context): ArgusRelease? {
val json = requestText(ARGUS_MANIFEST_URL) ?: return null
val release = JSONObject(json).optJSONObject("release")
?: error(context.getString(R.string.update_release_not_found))
val version = release.getString("version")
val channel = release.optString("channel")
val platform = release.optString("platform")
val packageKind = release.optString("packageKind")
val downloadPath = release.getString("downloadPath")
val sha256 = release.optString("sha256")
val packageSizeBytes = release.optLong("packageSizeBytes", -1L)
if (channel != ARGUS_CHANNEL ||
platform != ARGUS_PLATFORM ||
packageKind != ARGUS_PACKAGE_KIND ||
sha256.isBlank() ||
packageSizeBytes <= 0L
) {
error(context.getString(R.string.update_metadata_error))
}
return ArgusRelease(
version = release.getString("version"),
androidVersionCode = release.getInt("androidVersionCode"),
downloadUrl = release.getString("downloadPath").toAbsoluteArgusUrl(),
sha256 = release.optString("sha256")
version = version,
androidVersionCode = release.optInt("androidVersionCode").takeIf { it > 0 },
downloadUrl = downloadPath.toAbsoluteArgusUrl(),
packageSizeBytes = packageSizeBytes,
sha256 = sha256.lowercase()
)
}
private fun requestText(url: String): String {
private fun requestText(url: String): String? {
val connection = URL(url).openConnection() as HttpURLConnection
return try {
connection.connectTimeout = NETWORK_TIMEOUT_MS
connection.readTimeout = NETWORK_TIMEOUT_MS
connection.requestMethod = "GET"
if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
return null
}
check(connection.responseCode in 200..299) {
"HTTP ${connection.responseCode}"
}
@@ -170,6 +193,9 @@ class AppUpdateManager(private val fragment: Fragment) {
private fun downloadApk(context: Context, release: ArgusRelease): File {
val updatesDir = File(context.filesDir, "updates").apply { mkdirs() }
val apkFile = File(updatesDir, UPDATE_APK_NAME)
if (apkFile.exists()) {
apkFile.delete()
}
val connection = URL(release.downloadUrl).openConnection() as HttpURLConnection
try {
@@ -188,7 +214,13 @@ class AppUpdateManager(private val fragment: Fragment) {
connection.disconnect()
}
if (release.sha256.isNotBlank() && apkFile.sha256() != release.sha256.lowercase()) {
if (apkFile.length() != release.packageSizeBytes) {
apkFile.delete()
error(context.getString(R.string.update_metadata_error))
}
if (apkFile.sha256() != release.sha256) {
apkFile.delete()
error(context.getString(R.string.update_digest_error))
}
@@ -220,7 +252,10 @@ class AppUpdateManager(private val fragment: Fragment) {
if (downloadedVersionCode <= BuildConfig.VERSION_CODE) {
error(context.getString(R.string.update_version_error))
}
if (downloadedVersionCode != release.androidVersionCode.toLong()) {
if (release.androidVersionCode != null && downloadedVersionCode != release.androidVersionCode.toLong()) {
error(context.getString(R.string.update_metadata_error))
}
if (downloadedPackageInfo.versionName != release.version) {
error(context.getString(R.string.update_metadata_error))
}
@@ -326,10 +361,29 @@ class AppUpdateManager(private val fragment: Fragment) {
return digest.digest().joinToString("") { "%02x".format(it) }
}
private fun compareVersions(candidate: String, installed: String): Int {
val candidateParts = candidate.toVersionParts()
val installedParts = installed.toVersionParts()
val maxParts = maxOf(candidateParts.size, installedParts.size)
for (index in 0 until maxParts) {
val candidatePart = candidateParts.getOrElse(index) { 0 }
val installedPart = installedParts.getOrElse(index) { 0 }
if (candidatePart != installedPart) {
return candidatePart.compareTo(installedPart)
}
}
return candidate.compareTo(installed, ignoreCase = true)
}
private fun String.toVersionParts(): List<Int> =
split('.', '-', '_')
.mapNotNull { part -> part.takeWhile { it.isDigit() }.toIntOrNull() }
private data class ArgusRelease(
val version: String,
val androidVersionCode: Int,
val androidVersionCode: Int?,
val downloadUrl: String,
val packageSizeBytes: Long,
val sha256: String
)
@@ -337,8 +391,11 @@ class AppUpdateManager(private val fragment: Fragment) {
companion object {
private const val ARGUS_BASE_URL = "https://argus.kusoft.xyz"
private const val ARGUS_PLATFORM = "android"
private const val ARGUS_CHANNEL = "stable"
private const val ARGUS_PACKAGE_KIND = "apk"
private const val ARGUS_MANIFEST_URL =
"$ARGUS_BASE_URL/api/apps/qsfera-mobile/manifest?platform=android&channel=stable"
"$ARGUS_BASE_URL/api/apps/qsfera-mobile/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"
private const val UPDATE_APK_NAME = "qsfera-update.apk"
private const val APK_MIME_TYPE = "application/vnd.android.package-archive"
private const val NETWORK_TIMEOUT_MS = 15_000
@@ -110,7 +110,7 @@
<string name="update_checking">Проверка обновлений…</string>
<string name="update_downloading">Скачивание обновления…</string>
<string name="update_available_title">Доступно обновление</string>
<string name="update_available_message">Доступна версия %1$s (%2$d). Текущая версия: %3$s (%4$d).</string>
<string name="update_available_message">Доступна версия %1$s. Текущая версия: %2$s.</string>
<string name="update_not_available_title">Обновлений нет</string>
<string name="update_not_available_message">Установлена последняя доступная версия.</string>
<string name="update_install">Установить</string>
@@ -112,7 +112,7 @@
<string name="update_checking">Checking for updates…</string>
<string name="update_downloading">Downloading update…</string>
<string name="update_available_title">Update available</string>
<string name="update_available_message">Version %1$s (%2$d) is available. Current version: %3$s (%4$d).</string>
<string name="update_available_message">Version %1$s is available. Current version: %2$s.</string>
<string name="update_not_available_title">No updates</string>
<string name="update_not_available_message">You are using the latest available version.</string>
<string name="update_install">Install</string>
+54 -33
View File
@@ -9,6 +9,7 @@ param(
[string]$Notes,
[string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/QSfera.git',
[string]$HomepageUrl = 'https://qsfera.kusoft.xyz',
[string]$GitProvenanceRoot,
[switch]$Unlisted,
[switch]$SkipSigningInitialization,
[switch]$SkipGitProvenanceCheck
@@ -121,60 +122,74 @@ function Invoke-Git([string[]]$Arguments) {
return @($output)
}
function Assert-GitReleaseProvenance {
function Assert-GitReleaseProvenance([string]$RepositoryRoot) {
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
throw 'git was not found. Argus publication requires git provenance verification.'
}
$insideWorkTree = (Invoke-Git @('rev-parse', '--is-inside-work-tree') | Select-Object -First 1).Trim()
if ($insideWorkTree -ne 'true') {
throw 'Argus publication must be prepared from inside a git work tree.'
}
$status = Invoke-Git @('status', '--porcelain')
if ($status.Count -gt 0) {
throw "Git work tree has uncommitted changes. Commit or stash all changes before preparing Argus publication."
}
$branch = (Invoke-Git @('rev-parse', '--abbrev-ref', 'HEAD') | Select-Object -First 1).Trim()
if ($branch -eq 'HEAD') {
throw 'Git HEAD is detached. Check out the release branch before preparing Argus publication.'
}
$previousLocation = Get-Location
try {
$upstream = (Invoke-Git @('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}') | Select-Object -First 1).Trim()
} catch {
throw "Git branch has no upstream. Push $branch and set its upstream before Argus publication."
}
Set-Location $RepositoryRoot
Invoke-Git @('fetch', '--quiet')
$insideWorkTree = (Invoke-Git @('rev-parse', '--is-inside-work-tree') | Select-Object -First 1).Trim()
if ($insideWorkTree -ne 'true') {
throw 'Argus publication must be prepared from inside a git work tree.'
}
$aheadBehind = (Invoke-Git @('rev-list', '--left-right', '--count', 'HEAD...@{u}') | Select-Object -First 1).Trim() -split '\s+'
$ahead = [int]$aheadBehind[0]
$behind = [int]$aheadBehind[1]
if ($ahead -ne 0 -or $behind -ne 0) {
throw "Git HEAD must match upstream before Argus publication. Branch $branch tracks $upstream, ahead=$ahead, behind=$behind."
}
$status = Invoke-Git @('status', '--porcelain')
if ($status.Count -gt 0) {
throw "Git work tree has uncommitted changes. Commit or stash all changes before preparing Argus publication."
}
return [ordered]@{
branch = $branch
upstream = $upstream
commit = (Invoke-Git @('rev-parse', 'HEAD') | Select-Object -First 1).Trim()
verifiedAt = [DateTimeOffset]::UtcNow.ToString('O')
$branch = (Invoke-Git @('rev-parse', '--abbrev-ref', 'HEAD') | Select-Object -First 1).Trim()
if ($branch -eq 'HEAD') {
throw 'Git HEAD is detached. Check out the release branch before preparing Argus publication.'
}
try {
$upstream = (Invoke-Git @('rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}') | Select-Object -First 1).Trim()
} catch {
throw "Git branch has no upstream. Push $branch and set its upstream before Argus publication."
}
Invoke-Git @('fetch', '--quiet')
$aheadBehind = (Invoke-Git @('rev-list', '--left-right', '--count', 'HEAD...@{u}') | Select-Object -First 1).Trim() -split '\s+'
$ahead = [int]$aheadBehind[0]
$behind = [int]$aheadBehind[1]
if ($ahead -ne 0 -or $behind -ne 0) {
throw "Git HEAD must match upstream before Argus publication. Branch $branch tracks $upstream, ahead=$ahead, behind=$behind."
}
return [ordered]@{
repositoryRoot = (Invoke-Git @('rev-parse', '--show-toplevel') | Select-Object -First 1).Trim()
branch = $branch
upstream = $upstream
commit = (Invoke-Git @('rev-parse', 'HEAD') | Select-Object -First 1).Trim()
verifiedAt = [DateTimeOffset]::UtcNow.ToString('O')
}
} finally {
Set-Location $previousLocation
}
}
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $repoRoot
$gitProvenanceRootPath = if ($GitProvenanceRoot) {
(Resolve-Path -LiteralPath $GitProvenanceRoot).Path
} else {
$repoRoot
}
$gitProvenance = if ($SkipGitProvenanceCheck) {
[ordered]@{
skipped = $true
repositoryRoot = $gitProvenanceRootPath
reason = 'SkipGitProvenanceCheck was set'
verifiedAt = [DateTimeOffset]::UtcNow.ToString('O')
}
} else {
Assert-GitReleaseProvenance
Assert-GitReleaseProvenance $gitProvenanceRootPath
}
$javaHome = Resolve-JavaHome
@@ -299,6 +314,8 @@ if (-not (Test-Path $stageDirectory)) {
$stagedApkName = "{0}-{1}.apk" -f $Slug, $versionCode
$stagedApkPath = Join-Path $stageDirectory $stagedApkName
Copy-Item $releaseApkPath $stagedApkPath -Force
$stagedApkSize = (Get-Item -LiteralPath $stagedApkPath).Length
$stagedApkSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $stagedApkPath).Hash.ToLowerInvariant()
$metadata = [ordered]@{
packagePath = $stagedApkPath
@@ -313,6 +330,10 @@ $metadata = [ordered]@{
notes = if ($Notes) { $Notes } else { $null }
repositoryUrl = if ($RepositoryUrl) { $RepositoryUrl } else { $null }
homepageUrl = if ($HomepageUrl) { $HomepageUrl } else { $null }
packageSizeBytes = $stagedApkSize
sha256 = $stagedApkSha256
publicationMethod = 'ssh-sqlite-data-packages'
publicationInstruction = 'G:/Repos/Marketplace/ARGUS_PUBLICATION.md'
git = $gitProvenance
androidPackageName = $packageName
androidVersionCode = [int]$versionCode
+353 -122
View File
@@ -1,141 +1,372 @@
param(
[string]$BaseUrl = 'https://argus.kusoft.xyz',
[Parameter(Mandatory = $true)]
[string]$Username,
[Parameter(Mandatory = $true)]
[string]$Password,
[Parameter(Mandatory = $true)]
[string]$Slug,
[Parameter(Mandatory = $true)]
[string]$Name,
[Parameter(Mandatory = $true)]
[string]$Summary,
[Parameter(Mandatory = $true)]
[string]$Description,
[Parameter(Mandatory = $true)]
[string]$AndroidPackageName,
[Parameter(Mandatory = $true)]
[string]$Version,
[Parameter(Mandatory = $true)]
[ValidateRange(1, [int]::MaxValue)]
[int]$AndroidVersionCode,
[Parameter(Mandatory = $true)]
[string]$PackagePath,
[string]$Channel = 'stable',
[string]$Platform = 'android',
[string]$PackageKind = 'apk',
[string]$Notes,
[string]$RepositoryUrl,
[string]$HomepageUrl = 'https://qsfera.kusoft.xyz',
[switch]$Unlisted
[string]$MetadataPath,
[string]$PublicBaseUrl = 'https://argus.kusoft.xyz',
[string]$RemoteHost = '192.168.0.185',
[string]$RemoteUser = 'sevenhill',
[int]$RemotePort = 22,
[string]$RemoteTempDirectory = '/tmp',
[string]$ArgusData = '/srv/argus-data',
[string]$GeneratedScriptPath,
[switch]$PrepareOnly,
[switch]$RestartArgus
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Net.Http
function Read-HttpBody([System.Net.Http.HttpResponseMessage]$Response) {
if ($null -eq $Response.Content) {
return ''
function Fail([string]$Message) {
Write-Error $Message
exit 1
}
function Get-LatestMetadataPath {
$repoRoot = Split-Path -Parent $PSScriptRoot
$publicationRoot = Join-Path $repoRoot 'build\argus-publication'
if (-not (Test-Path -LiteralPath $publicationRoot)) {
Fail "Argus publication directory was not found: $publicationRoot"
}
return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
function Ensure-Success(
[System.Net.Http.HttpResponseMessage]$Response,
[string]$Operation
) {
if ($Response.IsSuccessStatusCode) {
return
$metadata = Get-ChildItem -LiteralPath $publicationRoot -Recurse -File -Filter 'argus-publication.json' |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (-not $metadata) {
Fail "No argus-publication.json file was found under $publicationRoot"
}
$body = Read-HttpBody $Response
throw "$Operation failed with status $([int]$Response.StatusCode) ($($Response.ReasonPhrase)). Body: $body"
return $metadata.FullName
}
function New-JsonContent([string]$Json) {
return [System.Net.Http.StringContent]::new($Json, [System.Text.Encoding]::UTF8, 'application/json')
function Get-RequiredString($Object, [string]$Name) {
$property = $Object.PSObject.Properties[$Name]
if ($null -eq $property -or [string]::IsNullOrWhiteSpace([string]$property.Value)) {
Fail "Publication metadata is missing '$Name'."
}
return [string]$property.Value
}
$resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path
if (-not (Test-Path $resolvedPackagePath)) {
throw "Package file was not found: $resolvedPackagePath"
function Quote-BashSingle([string]$Value) {
if ($null -eq $Value) {
$Value = ''
}
return "'" + ($Value -replace "'", "'\''") + "'"
}
$baseUri = $BaseUrl.TrimEnd('/')
$cookieContainer = [System.Net.CookieContainer]::new()
$handler = [System.Net.Http.HttpClientHandler]::new()
$handler.CookieContainer = $cookieContainer
$handler.UseCookies = $true
$client = [System.Net.Http.HttpClient]::new($handler)
$client.Timeout = [TimeSpan]::FromMinutes(15)
function Join-RemotePath([string]$Directory, [string]$Leaf) {
return $Directory.TrimEnd('/') + '/' + $Leaf
}
function Invoke-CheckedNative([string]$Program, [string[]]$Arguments) {
& $Program @Arguments
if ($LASTEXITCODE -ne 0) {
Fail "$Program failed with exit code $LASTEXITCODE."
}
}
function Assert-Slug([string]$Slug) {
if ($Slug -notmatch '^[a-z0-9][a-z0-9-]{0,99}$') {
Fail "Argus slug must contain only lowercase letters, digits, and hyphens, max 100 chars: $Slug"
}
}
function Assert-ManifestValue($Manifest, [string]$Name, $Actual, $Expected) {
if ([string]$Actual -ne [string]$Expected) {
Fail "Published manifest $Name expected '$Expected' but got '$Actual'."
}
}
if ([string]::IsNullOrWhiteSpace($MetadataPath)) {
$MetadataPath = Get-LatestMetadataPath
}
$resolvedMetadataPath = (Resolve-Path -LiteralPath $MetadataPath).Path
$metadata = Get-Content -LiteralPath $resolvedMetadataPath -Raw -Encoding UTF8 | ConvertFrom-Json
$packagePath = (Resolve-Path -LiteralPath (Get-RequiredString $metadata 'packagePath')).Path
$slug = Get-RequiredString $metadata 'slug'
$name = Get-RequiredString $metadata 'name'
$summary = Get-RequiredString $metadata 'summary'
$description = Get-RequiredString $metadata 'description'
$version = Get-RequiredString $metadata 'version'
$channel = (Get-RequiredString $metadata 'channel').ToLowerInvariant()
$platform = (Get-RequiredString $metadata 'platform').ToLowerInvariant()
$packageKind = (Get-RequiredString $metadata 'packageKind').ToLowerInvariant()
$notes = if ($metadata.PSObject.Properties['notes']) { [string]$metadata.notes } else { '' }
$repositoryUrl = if ($metadata.PSObject.Properties['repositoryUrl']) { [string]$metadata.repositoryUrl } else { '' }
$homepageUrl = if ($metadata.PSObject.Properties['homepageUrl']) { [string]$metadata.homepageUrl } else { '' }
$isListed = if ($metadata.PSObject.Properties['isListed'] -and [bool]$metadata.isListed) { '1' } else { '0' }
Assert-Slug $slug
if ($platform -ne 'android') {
Fail "QSfera Android publication requires platform=android. Actual platform: $platform"
}
if ($packageKind -ne 'apk') {
Fail "QSfera Android publication requires packageKind=apk. Actual packageKind: $packageKind"
}
if (-not $packagePath.EndsWith('.apk', [System.StringComparison]::OrdinalIgnoreCase)) {
Fail "QSfera Android publication requires an APK package: $packagePath"
}
$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 SSH-only Argus publication.'
}
$localSize = (Get-Item -LiteralPath $packagePath).Length
$localSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $packagePath).Hash.ToLowerInvariant()
$packageFileName = Split-Path -Leaf $packagePath
$remotePackagePath = Join-RemotePath $RemoteTempDirectory $packageFileName
$token = [guid]::NewGuid().ToString('N')
$remoteScriptPath = Join-RemotePath $RemoteTempDirectory "$slug-publish-$token.sh"
$localScriptPath = if ([string]::IsNullOrWhiteSpace($GeneratedScriptPath)) {
Join-Path ([System.IO.Path]::GetTempPath()) "$slug-publish-$token.sh"
} else {
$GeneratedScriptPath
}
$remote = "$RemoteUser@$RemoteHost"
$sshPort = [string]$RemotePort
$restartCommand = if ($RestartArgus) {
'docker restart argus >/dev/null'
} else {
':'
}
$scriptTemplate = @'
set -euo pipefail
cleanup() {
rm -f "$SOURCE_FILE" "$0"
}
trap cleanup EXIT
export ARGUS_DATA=__ARGUS_DATA__
export SOURCE_FILE=__SOURCE_FILE__
export ARGUS_SLUG=__ARGUS_SLUG__
export ARGUS_NAME=__ARGUS_NAME__
export ARGUS_SUMMARY=__ARGUS_SUMMARY__
export ARGUS_DESCRIPTION=__ARGUS_DESCRIPTION__
export ARGUS_REPOSITORY_URL=__ARGUS_REPOSITORY_URL__
export ARGUS_HOMEPAGE_URL=__ARGUS_HOMEPAGE_URL__
export ARGUS_IS_LISTED=__ARGUS_IS_LISTED__
export ARGUS_VERSION=__ARGUS_VERSION__
export ARGUS_CHANNEL=__ARGUS_CHANNEL__
export ARGUS_PLATFORM=__ARGUS_PLATFORM__
export ARGUS_PACKAGE_KIND=__ARGUS_PACKAGE_KIND__
export ARGUS_NOTES=__ARGUS_NOTES__
python3 - "$SOURCE_FILE" <<'PY'
import hashlib
import mimetypes
import os
import re
import shutil
import sqlite3
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
source = Path(sys.argv[1]).resolve()
data_root = Path(os.environ.get("ARGUS_DATA", "/srv/argus-data")).resolve()
db_path = data_root / "argus.db"
packages_root = data_root / "Packages"
slug = os.environ["ARGUS_SLUG"].strip()
name = os.environ["ARGUS_NAME"].strip()
summary = os.environ["ARGUS_SUMMARY"].strip()
description = os.environ["ARGUS_DESCRIPTION"].strip()
repository_url = os.environ.get("ARGUS_REPOSITORY_URL", "").strip() or None
homepage_url = os.environ.get("ARGUS_HOMEPAGE_URL", "").strip() or None
is_listed = 1 if os.environ.get("ARGUS_IS_LISTED", "1").strip() != "0" else 0
version = os.environ["ARGUS_VERSION"].strip()
channel = os.environ.get("ARGUS_CHANNEL", "stable").strip().lower()
platform = os.environ.get("ARGUS_PLATFORM", "generic").strip().lower()
package_kind = os.environ.get("ARGUS_PACKAGE_KIND", "binary").strip().lower()
notes = os.environ.get("ARGUS_NOTES", "").strip() or None
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,99}", slug):
raise SystemExit("ARGUS_SLUG must contain only lowercase letters, digits, and hyphens, max 100 chars.")
if not source.is_file():
raise SystemExit(f"SOURCE_FILE does not exist: {source}")
if not db_path.is_file():
raise SystemExit(f"Argus database does not exist: {db_path}")
for key, value in {
"ARGUS_NAME": name,
"ARGUS_SUMMARY": summary,
"ARGUS_DESCRIPTION": description,
"ARGUS_VERSION": version,
}.items():
if not value:
raise SystemExit(f"{key} is required.")
release_id = str(uuid.uuid4()).upper()
now = datetime.now(timezone.utc).isoformat(timespec="microseconds")
safe_version = re.sub(r"[^a-zA-Z0-9._-]+", "-", version).strip("-._") or "release"
extension = source.suffix or ".bin"
stored_name = f"{datetime.now(timezone.utc):%Y%m%d%H%M%S}-{safe_version}-{release_id.replace('-', '')}{extension}"
stored_relative_path = f"{slug}/{stored_name}"
target_dir = packages_root / slug
target_path = target_dir / stored_name
target_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target_path)
size_bytes = target_path.stat().st_size
sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest()
content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
if source.suffix.lower() == ".apk":
content_type = "application/vnd.android.package-archive"
conn = sqlite3.connect(db_path)
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
row = conn.execute('SELECT "Id" FROM "Apps" WHERE "Slug" = ?', (slug,)).fetchone()
if row is None:
app_id = str(uuid.uuid4()).upper()
conn.execute(
'''
INSERT INTO "Apps"
("Id", "Slug", "Name", "Summary", "Description", "RepositoryUrl", "HomepageUrl", "IsListed", "CreatedAt", "UpdatedAt")
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(app_id, slug, name, summary, description, repository_url, homepage_url, is_listed, now, now),
)
else:
app_id = row[0]
conn.execute(
'''
UPDATE "Apps"
SET "Name" = ?,
"Summary" = ?,
"Description" = ?,
"RepositoryUrl" = ?,
"HomepageUrl" = ?,
"IsListed" = ?,
"UpdatedAt" = ?
WHERE "Id" = ?
''',
(name, summary, description, repository_url, homepage_url, is_listed, now, app_id),
)
duplicate = conn.execute(
'''
SELECT "Id"
FROM "Releases"
WHERE "CatalogAppId" = ? AND "Version" = ? AND "Channel" = ? AND "Platform" = ?
''',
(app_id, version, channel, platform),
).fetchone()
if duplicate is not None:
raise RuntimeError(f"Release already exists for {slug} {version} {channel} {platform}.")
conn.execute(
'''
INSERT INTO "Releases"
("Id", "CatalogAppId", "Version", "Channel", "Platform", "PackageKind",
"OriginalFileName", "StoredRelativePath", "ContentType", "PackageSizeBytes",
"Sha256", "Notes", "PublishedAt")
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(
release_id,
app_id,
version,
channel,
platform,
package_kind,
source.name,
stored_relative_path,
content_type,
size_bytes,
sha256,
notes,
now,
),
)
conn.commit()
except Exception:
conn.rollback()
try:
target_path.unlink()
except FileNotFoundError:
pass
raise
finally:
conn.close()
print(f"Published app={slug} version={version} channel={channel} platform={platform}")
print(f"StoredRelativePath={stored_relative_path}")
print(f"Size={size_bytes}")
print(f"Sha256={sha256}")
print(f"Manifest=https://argus.kusoft.xyz/api/apps/{slug}/manifest?platform={platform}&channel={channel}")
PY
curl -fsS "http://127.0.0.1:5105/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"
__RESTART_COMMAND__
'@
$publicationScript = $scriptTemplate.
Replace('__ARGUS_DATA__', (Quote-BashSingle $ArgusData)).
Replace('__SOURCE_FILE__', (Quote-BashSingle $remotePackagePath)).
Replace('__ARGUS_SLUG__', (Quote-BashSingle $slug)).
Replace('__ARGUS_NAME__', (Quote-BashSingle $name)).
Replace('__ARGUS_SUMMARY__', (Quote-BashSingle $summary)).
Replace('__ARGUS_DESCRIPTION__', (Quote-BashSingle $description)).
Replace('__ARGUS_REPOSITORY_URL__', (Quote-BashSingle $repositoryUrl)).
Replace('__ARGUS_HOMEPAGE_URL__', (Quote-BashSingle $homepageUrl)).
Replace('__ARGUS_IS_LISTED__', (Quote-BashSingle $isListed)).
Replace('__ARGUS_VERSION__', (Quote-BashSingle $version)).
Replace('__ARGUS_CHANNEL__', (Quote-BashSingle $channel)).
Replace('__ARGUS_PLATFORM__', (Quote-BashSingle $platform)).
Replace('__ARGUS_PACKAGE_KIND__', (Quote-BashSingle $packageKind)).
Replace('__ARGUS_NOTES__', (Quote-BashSingle $notes)).
Replace('__RESTART_COMMAND__', $restartCommand)
[System.IO.File]::WriteAllText($localScriptPath, $publicationScript, [System.Text.UTF8Encoding]::new($false))
if ($PrepareOnly) {
Write-Output 'QSfera Android Argus SSH publication script prepared.'
Write-Output "Script: $localScriptPath"
Write-Output "APK: $packagePath"
Write-Output "Remote APK: $remotePackagePath"
Write-Output "Size: $localSize"
Write-Output "SHA-256: $localSha256"
exit 0
}
try {
$loginPayload = @{
username = $Username
password = $Password
} | ConvertTo-Json -Compress
$loginResponse = $client.PostAsync(
"$baseUri/api/admin/session/login",
(New-JsonContent $loginPayload)
).GetAwaiter().GetResult()
Ensure-Success $loginResponse 'Argus login'
$appPayload = @{
name = $Name
summary = $Summary
description = $Description
androidPackageName = $AndroidPackageName
repositoryUrl = if ([string]::IsNullOrWhiteSpace($RepositoryUrl)) { $null } else { $RepositoryUrl }
homepageUrl = if ([string]::IsNullOrWhiteSpace($HomepageUrl)) { $null } else { $HomepageUrl }
isListed = (-not $Unlisted)
} | ConvertTo-Json -Compress
$appResponse = $client.PutAsync(
"$baseUri/api/admin/apps/$Slug",
(New-JsonContent $appPayload)
).GetAwaiter().GetResult()
Ensure-Success $appResponse 'Argus app metadata update'
$form = [System.Net.Http.MultipartFormDataContent]::new()
$fields = [ordered]@{
Version = $Version
Channel = $Channel
Platform = $Platform
PackageKind = $PackageKind
AndroidVersionCode = [string]$AndroidVersionCode
}
foreach ($entry in $fields.GetEnumerator()) {
$form.Add([System.Net.Http.StringContent]::new($entry.Value, [System.Text.Encoding]::UTF8), $entry.Key)
}
if (-not [string]::IsNullOrWhiteSpace($Notes)) {
$form.Add([System.Net.Http.StringContent]::new($Notes, [System.Text.Encoding]::UTF8), 'Notes')
}
$fileStream = [System.IO.File]::OpenRead($resolvedPackagePath)
try {
$fileContent = [System.Net.Http.StreamContent]::new($fileStream)
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/vnd.android.package-archive')
$form.Add($fileContent, 'Package', [System.IO.Path]::GetFileName($resolvedPackagePath))
$releaseResponse = $client.PostAsync(
"$baseUri/api/admin/apps/$Slug/releases",
$form
).GetAwaiter().GetResult()
Ensure-Success $releaseResponse 'Argus release upload'
$releaseBody = Read-HttpBody $releaseResponse
if (-not [string]::IsNullOrWhiteSpace($releaseBody)) {
Write-Output $releaseBody
}
} finally {
$fileStream.Dispose()
$form.Dispose()
}
Invoke-CheckedNative $scp.Source @('-P', $sshPort, $packagePath, "${remote}:$remotePackagePath")
Invoke-CheckedNative $scp.Source @('-P', $sshPort, $localScriptPath, "${remote}:$remoteScriptPath")
Invoke-CheckedNative $ssh.Source @('-p', $sshPort, $remote, "bash $(Quote-BashSingle $remoteScriptPath)")
} finally {
$client.Dispose()
$handler.Dispose()
Remove-Item -LiteralPath $localScriptPath -Force -ErrorAction SilentlyContinue
}
$manifestUrl = "$($PublicBaseUrl.TrimEnd('/'))/api/apps/$slug/manifest?platform=$platform&channel=$channel"
$manifest = Invoke-RestMethod -Uri $manifestUrl -TimeoutSec 30
$release = $manifest.release
if ($null -eq $release) {
Fail "Published manifest did not return a release: $manifestUrl"
}
Assert-ManifestValue $manifest 'slug' $manifest.slug $slug
Assert-ManifestValue $release 'version' $release.version $version
Assert-ManifestValue $release 'channel' $release.channel $channel
Assert-ManifestValue $release 'platform' $release.platform $platform
Assert-ManifestValue $release 'packageKind' $release.packageKind $packageKind
Assert-ManifestValue $release 'originalFileName' $release.originalFileName $packageFileName
Assert-ManifestValue $release 'packageSizeBytes' $release.packageSizeBytes $localSize
Assert-ManifestValue $release 'sha256' $release.sha256 $localSha256
Write-Output 'QSfera Android Argus SSH publication completed.'
Write-Output "Manifest: $manifestUrl"
Write-Output "Size: $localSize"
Write-Output "SHA-256: $localSha256"
@@ -0,0 +1,183 @@
param(
[string]$PropertiesPath = 'argus-signing.local.properties',
[string]$ExpectedCertificateSha256 = 'ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877',
[string]$PublishedApkUrl = 'https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable',
[switch]$VerifyPublishedApk
)
$ErrorActionPreference = 'Stop'
function Fail([string]$Message) {
Write-Error $Message
exit 1
}
function Read-Properties([string]$Path) {
$result = @{}
foreach ($line in Get-Content -LiteralPath $Path) {
$trimmed = $line.Trim()
if ($trimmed.Length -eq 0 -or $trimmed.StartsWith('#')) {
continue
}
$separator = $trimmed.IndexOf('=')
if ($separator -le 0) {
continue
}
$key = $trimmed.Substring(0, $separator).Trim()
$value = $trimmed.Substring($separator + 1).Trim()
$result[$key] = $value
}
return $result
}
function Resolve-JavaHome {
$candidates = @(
$env:JAVA_HOME,
'C:\Program Files\Android\Android Studio\jbr',
'C:\Program Files\Android\openjdk\jdk-21.0.8'
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($candidate in $candidates) {
$keytool = Join-Path $candidate 'bin\keytool.exe'
if (Test-Path -LiteralPath $keytool) {
return $candidate
}
}
Fail 'keytool.exe was not found. Set JAVA_HOME to a JDK path.'
}
function Resolve-AndroidHome {
$candidates = @(
$env:ANDROID_HOME,
"$env:LOCALAPPDATA\Android\Sdk"
) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
foreach ($candidate in $candidates) {
if (Test-Path -LiteralPath (Join-Path $candidate 'build-tools')) {
return $candidate
}
}
Fail 'Android SDK was not found. Set ANDROID_HOME to the SDK path.'
}
function Find-FirstTool([string]$AndroidHome, [string]$ToolName) {
$tool = Get-ChildItem -Path (Join-Path $AndroidHome 'build-tools') -Recurse -File -Filter $ToolName |
Sort-Object FullName -Descending |
Select-Object -First 1
if (-not $tool) {
Fail "$ToolName was not found under Android build-tools."
}
return $tool.FullName
}
function Normalize-Sha256([string]$Value) {
return ($Value -replace '[^0-9a-fA-F]', '').ToLowerInvariant()
}
function Get-KeytoolCertificateSha256([string]$KeytoolPath, [string]$KeystorePath, [string]$Password, [string]$Alias) {
$env:QSFERA_VERIFY_KEYSTORE_PASSWORD = $Password
try {
$output = & $KeytoolPath -list -v -keystore $KeystorePath -storepass:env QSFERA_VERIFY_KEYSTORE_PASSWORD -alias $Alias 2>&1
if ($LASTEXITCODE -ne 0) {
Fail "keytool could not read the release keystore alias '$Alias'. Output: $($output | Out-String)"
}
} finally {
Remove-Item Env:\QSFERA_VERIFY_KEYSTORE_PASSWORD -ErrorAction SilentlyContinue
}
$text = $output | Out-String
$match = [regex]::Match($text, 'SHA256:\s*([0-9A-Fa-f:]+)')
if (-not $match.Success) {
Fail 'Could not extract SHA256 certificate fingerprint from keytool output.'
}
return Normalize-Sha256 $match.Groups[1].Value
}
function Get-ApkCertificateSha256([string]$ApksignerPath, [string]$ApkPath) {
$output = & $ApksignerPath verify --print-certs $ApkPath 2>&1
if ($LASTEXITCODE -ne 0) {
Fail "apksigner could not verify APK certificate. Output: $($output | Out-String)"
}
$text = $output | Out-String
$match = [regex]::Match($text, 'SHA-256 digest:\s*([0-9A-Fa-f]+)')
if (-not $match.Success) {
Fail 'Could not extract SHA-256 certificate fingerprint from apksigner output.'
}
return Normalize-Sha256 $match.Groups[1].Value
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$resolvedPropertiesPath = if ([System.IO.Path]::IsPathRooted($PropertiesPath)) {
$PropertiesPath
} else {
Join-Path $repoRoot $PropertiesPath
}
if (-not (Test-Path -LiteralPath $resolvedPropertiesPath)) {
Fail "Signing properties were not found: $resolvedPropertiesPath"
}
$properties = Read-Properties $resolvedPropertiesPath
foreach ($key in @(
'QSFERA_RELEASE_KEYSTORE',
'QSFERA_RELEASE_KEYSTORE_PASSWORD',
'QSFERA_RELEASE_KEY_ALIAS',
'QSFERA_RELEASE_KEY_PASSWORD'
)) {
if (-not $properties.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($properties[$key])) {
Fail "Signing property is missing: $key"
}
}
$keystorePath = $properties['QSFERA_RELEASE_KEYSTORE']
if (-not [System.IO.Path]::IsPathRooted($keystorePath)) {
$keystorePath = Join-Path $repoRoot $keystorePath
}
if (-not (Test-Path -LiteralPath $keystorePath)) {
Fail "Release keystore was not found: $keystorePath"
}
if ((Get-Item -LiteralPath $keystorePath).Length -le 0) {
Fail "Release keystore is empty: $keystorePath"
}
$javaHome = Resolve-JavaHome
$env:JAVA_HOME = $javaHome
$keytoolPath = Join-Path $javaHome 'bin\keytool.exe'
$expectedSha256 = Normalize-Sha256 $ExpectedCertificateSha256
$alias = $properties['QSFERA_RELEASE_KEY_ALIAS']
$keytoolSha256 = Get-KeytoolCertificateSha256 `
-KeytoolPath $keytoolPath `
-KeystorePath $keystorePath `
-Password $properties['QSFERA_RELEASE_KEYSTORE_PASSWORD'] `
-Alias $alias
if ($keytoolSha256 -ne $expectedSha256) {
Fail "Release keystore certificate SHA-256 expected $expectedSha256 but got $keytoolSha256."
}
Write-Output "OK: release signing properties are complete: $resolvedPropertiesPath"
Write-Output "OK: release keystore exists and is readable: $keystorePath"
Write-Output "OK: release key alias '$alias' certificate SHA-256 = $keytoolSha256"
if ($VerifyPublishedApk) {
$androidHome = Resolve-AndroidHome
$apksignerPath = Find-FirstTool $androidHome 'apksigner.bat'
$tempApk = [System.IO.Path]::ChangeExtension([System.IO.Path]::GetTempFileName(), '.apk')
try {
Invoke-WebRequest -Uri $PublishedApkUrl -UseBasicParsing -TimeoutSec 120 -OutFile $tempApk | Out-Null
$publishedSha256 = Get-ApkCertificateSha256 -ApksignerPath $apksignerPath -ApkPath $tempApk
if ($publishedSha256 -ne $expectedSha256) {
Fail "Published APK certificate SHA-256 expected $expectedSha256 but got $publishedSha256."
}
Write-Output "OK: published Argus APK certificate SHA-256 = $publishedSha256"
} finally {
Remove-Item -LiteralPath $tempApk -Force -ErrorAction SilentlyContinue
}
}
+4 -10
View File
@@ -37,17 +37,11 @@ go run .\services\keepernotes\cmd\keepernotes -addr :8098 -data .\keeper-notes.j
## Publish to Argus
The Argus publisher script uses the admin endpoints exposed by `https://argus.kusoft.xyz/site.js`:
Argus publication follows `G:\Repos\Marketplace\ARGUS_PUBLICATION.md`: copy the APK over SSH, then insert release metadata into `/srv/argus-data/argus.db` on the Argus host. The app must only use the public read-only manifest API for updates.
* `POST /api/admin/session/login`
* `PUT /api/admin/apps/{slug}`
* `POST /api/admin/apps/{slug}/releases`
Set credentials in the current shell, then run:
Build the APK, then run:
```powershell
$env:ARGUS_USERNAME='admin'
$env:ARGUS_PASSWORD='<password>'
.\scripts\publish-argus.ps1
```
@@ -57,6 +51,6 @@ Default publish metadata:
* package: `eu.qsfera.keeper`
* name: `QKeep`
* Android label: `КуЗаметки`
* version: `0.1.21`
* Android version code: `22`
* version: `0.1.22`
* Android version code: `23`
* channel: `stable`
+2 -2
View File
@@ -23,8 +23,8 @@ android {
applicationId "eu.qsfera.keeper"
minSdkVersion sdkMinVersion
targetSdkVersion sdkTargetVersion
versionCode 22
versionName "0.1.21"
versionCode 23
versionName "0.1.22"
def apiBaseUrl = project.findProperty("keeperApiBaseUrl") ?: "https://qsfera.kusoft.xyz/keeper-notes"
def cloudBaseUrl = project.findProperty("qsferaCloudBaseUrl") ?: "https://qsfera.kusoft.xyz"
@@ -161,6 +161,7 @@ enum class BodyTextStyle {
class MainActivity : AppCompatActivity() {
private lateinit var authPreferences: AuthPreferences
private lateinit var keepSettings: KeepSettings
private lateinit var versionCleanup: VersionCleanup
private lateinit var authClient: QsferaAuthClient
private lateinit var repository: NotesRepository
private val updateClient = AppUpdateClient(ARGUS_MANIFEST_URL)
@@ -183,10 +184,11 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState)
authPreferences = AuthPreferences(applicationContext)
keepSettings = KeepSettings(applicationContext)
versionCleanup = VersionCleanup(applicationContext)
authClient = QsferaAuthClient(BuildConfig.QSFERA_CLOUD_BASE_URL)
repository = NotesRepository(applicationContext, authPreferences, authClient)
configureSystemBars()
VersionCleanup(applicationContext).cleanupAfterUpdate(BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME)
versionCleanup.cleanupAfterUpdate(BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME)
ReminderScheduler(applicationContext).ensureNotificationChannel()
ensureNotificationPermission()
@@ -799,12 +801,15 @@ class MainActivity : AppCompatActivity() {
checkButton.isEnabled = true
updateStatus.text = result.fold(
onSuccess = { latest ->
if (latest.androidVersionCode > BuildConfig.VERSION_CODE) {
val installedVersion = versionCleanup.installedVersionName(BuildConfig.VERSION_NAME)
if (latest == null) {
getString(R.string.no_update_available)
} else if (AppVersionComparator.isRemoteNewer(installedVersion, latest.version)) {
availableUpdate = latest
installButton.visibility = View.VISIBLE
getString(R.string.update_available, latest.version, latest.androidVersionCode)
getString(R.string.update_available, latest.version)
} else {
getString(R.string.up_to_date, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE)
getString(R.string.up_to_date, installedVersion)
}
},
onFailure = {
@@ -827,6 +832,7 @@ class MainActivity : AppCompatActivity() {
installButton.isEnabled = true
result
.onSuccess { apk ->
versionCleanup.markUpdateStaged(update.version)
updateStatus.text = getString(R.string.opening_installer)
installApk(apk)
}
@@ -3099,13 +3105,13 @@ data class PendingOAuth(
data class AppUpdateInfo(
val version: String,
val androidVersionCode: Int,
val downloadUrl: String,
val sha256: String?,
val sha256: String,
val packageSizeBytes: Long?,
)
class AppUpdateClient(private val manifestUrl: String) {
fun latest(): AppUpdateInfo {
fun latest(): AppUpdateInfo? {
val connection = (URL(manifestUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 8000
@@ -3116,16 +3122,22 @@ class AppUpdateClient(private val manifestUrl: String) {
return try {
val response = if (connection.responseCode in 200..299) {
connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
} else if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
return null
} else {
val errorText = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
throw IOException("HTTP ${connection.responseCode}: $errorText")
}
val release = JSONObject(response).getJSONObject("release")
val sha256 = release.optString("sha256").trim()
if (!sha256.matches(Regex("[0-9a-fA-F]{64}"))) {
throw IOException("Argus release sha256 is missing or invalid")
}
AppUpdateInfo(
version = release.getString("version"),
androidVersionCode = release.getInt("androidVersionCode"),
downloadUrl = release.getString("downloadPath").toAbsoluteArgusUrl(),
sha256 = release.optString("sha256").takeIf { it.isNotBlank() }
sha256 = sha256,
packageSizeBytes = release.optLong("packageSizeBytes", -1L).takeIf { it >= 0L }
)
} finally {
connection.disconnect()
@@ -3139,7 +3151,10 @@ class AppUpdateClient(private val manifestUrl: String) {
updateDir.mkdirs()
}
val apk = File(updateDir, "keeper-android-${update.version}.apk")
val tempApk = File(updateDir, "keeper-android-${update.version}.apk.download")
tempApk.delete()
val digest = MessageDigest.getInstance("SHA-256")
var downloadedBytes = 0L
val connection = (URL(update.downloadUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "GET"
connectTimeout = 8000
@@ -3153,25 +3168,38 @@ class AppUpdateClient(private val manifestUrl: String) {
throw IOException("HTTP ${connection.responseCode}: $errorText")
}
connection.inputStream.use { input ->
apk.outputStream().use { output ->
tempApk.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) {
val read = input.read(buffer)
if (read < 0) break
digest.update(buffer, 0, read)
output.write(buffer, 0, read)
downloadedBytes += read
}
}
}
} catch (error: Exception) {
tempApk.delete()
throw error
} finally {
connection.disconnect()
}
val actualSha256 = digest.digest().toHexString()
val expectedSha256 = update.sha256
if (expectedSha256 != null && !expectedSha256.equals(actualSha256, ignoreCase = true)) {
apk.delete()
if (!update.sha256.equals(actualSha256, ignoreCase = true)) {
tempApk.delete()
throw IOException("Downloaded APK checksum mismatch")
}
val expectedSize = update.packageSizeBytes
if (expectedSize != null && expectedSize != downloadedBytes) {
tempApk.delete()
throw IOException("Downloaded APK size mismatch")
}
apk.delete()
if (!tempApk.renameTo(apk)) {
tempApk.copyTo(apk, overwrite = true)
tempApk.delete()
}
return apk
}
@@ -3184,6 +3212,32 @@ class AppUpdateClient(private val manifestUrl: String) {
}
}
object AppVersionComparator {
private val semanticVersionPattern = Regex("""^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$""")
fun isRemoteNewer(installedVersion: String, remoteVersion: String): Boolean {
val installedSemanticVersion = installedVersion.toSemanticVersionParts()
val remoteSemanticVersion = remoteVersion.toSemanticVersionParts()
if (installedSemanticVersion != null && remoteSemanticVersion != null) {
installedSemanticVersion.indices.forEach { index ->
val comparison = remoteSemanticVersion[index].compareTo(installedSemanticVersion[index])
if (comparison != 0) return comparison > 0
}
return false
}
return remoteVersion.compareTo(installedVersion, ignoreCase = true) > 0
}
private fun String.toSemanticVersionParts(): List<Int>? {
val match = semanticVersionPattern.matchEntire(trim()) ?: return null
return listOf(
match.groupValues[1].toInt(),
match.groupValues[2].toInt(),
match.groupValues[3].toInt()
)
}
}
class VersionCleanup(private val context: Context) {
private val preferences = context.getSharedPreferences("keeper_runtime", Context.MODE_PRIVATE)
@@ -3193,10 +3247,26 @@ class VersionCleanup(private val context: Context) {
cleanupOldPackageFiles(currentVersionName)
preferences.edit()
.putInt(KEY_LAST_VERSION_CODE, currentVersionCode)
.putString(KEY_INSTALLED_VERSION_NAME, currentVersionName)
.apply()
} else if (preferences.getString(KEY_INSTALLED_VERSION_NAME, null) != currentVersionName) {
preferences.edit()
.putString(KEY_INSTALLED_VERSION_NAME, currentVersionName)
.apply()
}
}
fun installedVersionName(defaultVersionName: String): String {
return preferences.getString(KEY_INSTALLED_VERSION_NAME, null) ?: defaultVersionName
}
fun markUpdateStaged(versionName: String) {
preferences.edit()
.putString(KEY_STAGED_VERSION_NAME, versionName)
.putLong(KEY_STAGED_AT, System.currentTimeMillis())
.apply()
}
fun cleanupOldPackageFiles(currentVersionName: String) {
packageFileRoots().forEach { root ->
if (!root.exists()) return@forEach
@@ -3229,6 +3299,9 @@ class VersionCleanup(private val context: Context) {
private companion object {
const val KEY_LAST_VERSION_CODE = "last_version_code"
const val KEY_INSTALLED_VERSION_NAME = "installed_version_name"
const val KEY_STAGED_VERSION_NAME = "staged_version_name"
const val KEY_STAGED_AT = "staged_at"
}
}
@@ -127,8 +127,9 @@
<string name="checking_updates">Проверяю обновления…</string>
<string name="downloading_update">Скачиваю обновление…</string>
<string name="opening_installer">Открываю установщик Android…</string>
<string name="up_to_date">Установлена актуальная версия %1$s (%2$d).</string>
<string name="update_available">Доступна версия %1$s (%2$d).</string>
<string name="up_to_date">&#1059;&#1089;&#1090;&#1072;&#1085;&#1086;&#1074;&#1083;&#1077;&#1085;&#1072; &#1072;&#1082;&#1090;&#1091;&#1072;&#1083;&#1100;&#1085;&#1072;&#1103; &#1074;&#1077;&#1088;&#1089;&#1080;&#1103; %1$s.</string>
<string name="no_update_available">&#1042; Argus &#1085;&#1077;&#1090; &#1088;&#1077;&#1083;&#1080;&#1079;&#1072; &#1076;&#1083;&#1103; &#1101;&#1090;&#1086;&#1075;&#1086; &#1087;&#1088;&#1080;&#1083;&#1086;&#1078;&#1077;&#1085;&#1080;&#1103;.</string>
<string name="update_available">&#1044;&#1086;&#1089;&#1090;&#1091;&#1087;&#1085;&#1072; &#1074;&#1077;&#1088;&#1089;&#1080;&#1103; %1$s.</string>
<string name="update_check_failed">Не удалось проверить обновления.</string>
<string name="update_download_failed">Не удалось скачать обновление.</string>
<string name="allow_update_install">Разрешите установку из этого источника и повторите установку обновления.</string>
+210 -78
View File
@@ -1,99 +1,231 @@
param(
[string]$SshHost = $(if ($env:ARGUS_SSH_HOST) { $env:ARGUS_SSH_HOST } else { "192.168.0.185" }),
[string]$SshUser = $(if ($env:ARGUS_SSH_USER) { $env:ARGUS_SSH_USER } else { "sevenhill" }),
[string]$ArgusBaseUrl = $(if ($env:ARGUS_BASE_URL) { $env:ARGUS_BASE_URL } else { "https://argus.kusoft.xyz" }),
[string]$Username = $env:ARGUS_USERNAME,
[string]$Password = $env:ARGUS_PASSWORD,
[string]$ArgusData = $(if ($env:ARGUS_DATA) { $env:ARGUS_DATA } else { "/srv/argus-data" }),
[string]$ApkPath = "keeperApp\build\outputs\apk\debug\keeperApp-debug.apk",
[string]$Slug = "keeper-android",
[string]$Name = "QKeep",
[string]$Summary = "Cloud notes app for QSfera with local storage and sync.",
[string]$Description = "QKeep stores notes locally on Android, uses QSfera browser authorization, syncs notes with the Keeper REST service, and installs Argus updates from inside the app.",
[string]$AndroidPackageName = "eu.qsfera.keeper",
[string]$Version = "0.1.21",
[int]$AndroidVersionCode = 22,
[string]$Description = "QKeep stores notes locally on Android, uses QSfera browser authorization, syncs notes with the Keeper REST service, and installs SHA-256 verified Argus updates.",
[string]$RepositoryUrl = "",
[string]$HomepageUrl = "",
[string]$Version = "0.1.22",
[string]$Channel = "stable",
[string]$Platform = "android",
[string]$PackageKind = "apk",
[string]$Notes = "Adds Keep-like list behavior settings for new and checked checklist items."
[string]$Notes = "Aligns the Android updater and publication flow with ARGUS_PUBLICATION.md."
)
$ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($Username) -or [string]::IsNullOrWhiteSpace($Password)) {
throw "Set ARGUS_USERNAME and ARGUS_PASSWORD before publishing."
function ConvertTo-Base64Utf8([string]$Value) {
return [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Value))
}
function Assert-LastExitCode([string]$Action) {
if ($LASTEXITCODE -ne 0) {
throw "$Action failed with exit code $LASTEXITCODE."
}
}
$resolvedApk = (Resolve-Path -LiteralPath $ApkPath).Path
$baseUri = [Uri]($ArgusBaseUrl.TrimEnd("/"))
$slugEscaped = [Uri]::EscapeDataString($Slug)
$sshTarget = "$SshUser@$SshHost"
$remoteFile = "/tmp/$Slug-$Version.apk"
$manifestUrl = "$($ArgusBaseUrl.TrimEnd("/"))/api/apps/$Slug/manifest?platform=$Platform&channel=$Channel"
Add-Type -AssemblyName System.Net.Http
& scp "$resolvedApk" "${sshTarget}:$remoteFile"
Assert-LastExitCode "Copy APK to Argus host"
$handler = [System.Net.Http.HttpClientHandler]::new()
$handler.CookieContainer = [System.Net.CookieContainer]::new()
$client = [System.Net.Http.HttpClient]::new($handler)
$client.BaseAddress = $baseUri
$nameB64 = ConvertTo-Base64Utf8 $Name
$summaryB64 = ConvertTo-Base64Utf8 $Summary
$descriptionB64 = ConvertTo-Base64Utf8 $Description
$repositoryUrlB64 = ConvertTo-Base64Utf8 $RepositoryUrl
$homepageUrlB64 = ConvertTo-Base64Utf8 $HomepageUrl
$notesB64 = ConvertTo-Base64Utf8 $Notes
function New-JsonContent([object]$Value) {
$json = $Value | ConvertTo-Json -Depth 8
return [System.Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, "application/json")
$remoteScript = @"
set -euo pipefail
export ARGUS_DATA="$ArgusData"
export SOURCE_FILE="$remoteFile"
export ARGUS_SLUG="$Slug"
export ARGUS_NAME="`$(printf '%s' '$nameB64' | base64 -d)"
export ARGUS_SUMMARY="`$(printf '%s' '$summaryB64' | base64 -d)"
export ARGUS_DESCRIPTION="`$(printf '%s' '$descriptionB64' | base64 -d)"
export ARGUS_REPOSITORY_URL="`$(printf '%s' '$repositoryUrlB64' | base64 -d)"
export ARGUS_HOMEPAGE_URL="`$(printf '%s' '$homepageUrlB64' | base64 -d)"
export ARGUS_IS_LISTED="1"
export ARGUS_VERSION="$Version"
export ARGUS_CHANNEL="$Channel"
export ARGUS_PLATFORM="$Platform"
export ARGUS_PACKAGE_KIND="$PackageKind"
export ARGUS_NOTES="`$(printf '%s' '$notesB64' | base64 -d)"
trap 'rm -f "$SOURCE_FILE"' EXIT
python3 - "`$SOURCE_FILE" <<'PY'
import hashlib
import mimetypes
import os
import re
import shutil
import sqlite3
import sys
import uuid
from datetime import datetime, timezone
from pathlib import Path
source = Path(sys.argv[1]).resolve()
data_root = Path(os.environ.get("ARGUS_DATA", "/srv/argus-data")).resolve()
db_path = data_root / "argus.db"
packages_root = data_root / "Packages"
slug = os.environ["ARGUS_SLUG"].strip()
name = os.environ["ARGUS_NAME"].strip()
summary = os.environ["ARGUS_SUMMARY"].strip()
description = os.environ["ARGUS_DESCRIPTION"].strip()
repository_url = os.environ.get("ARGUS_REPOSITORY_URL", "").strip() or None
homepage_url = os.environ.get("ARGUS_HOMEPAGE_URL", "").strip() or None
is_listed = 1 if os.environ.get("ARGUS_IS_LISTED", "1").strip() != "0" else 0
version = os.environ["ARGUS_VERSION"].strip()
channel = os.environ.get("ARGUS_CHANNEL", "stable").strip().lower()
platform = os.environ.get("ARGUS_PLATFORM", "generic").strip().lower()
package_kind = os.environ.get("ARGUS_PACKAGE_KIND", "binary").strip().lower()
notes = os.environ.get("ARGUS_NOTES", "").strip() or None
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,99}", slug):
raise SystemExit("ARGUS_SLUG must contain only lowercase letters, digits, and hyphens, max 100 chars.")
if not source.is_file():
raise SystemExit(f"SOURCE_FILE does not exist: {source}")
if not db_path.is_file():
raise SystemExit(f"Argus database does not exist: {db_path}")
for key, value in {
"ARGUS_NAME": name,
"ARGUS_SUMMARY": summary,
"ARGUS_DESCRIPTION": description,
"ARGUS_VERSION": version,
}.items():
if not value:
raise SystemExit(f"{key} is required.")
release_id = str(uuid.uuid4()).upper()
now = datetime.now(timezone.utc).isoformat(timespec="microseconds")
safe_version = re.sub(r"[^a-zA-Z0-9._-]+", "-", version).strip("-._") or "release"
extension = source.suffix or ".bin"
stored_name = f"{datetime.now(timezone.utc):%Y%m%d%H%M%S}-{safe_version}-{release_id.replace('-', '')}{extension}"
stored_relative_path = f"{slug}/{stored_name}"
target_dir = packages_root / slug
target_path = target_dir / stored_name
target_dir.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target_path)
size_bytes = target_path.stat().st_size
sha256 = hashlib.sha256(target_path.read_bytes()).hexdigest()
content_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
if source.suffix.lower() == ".apk":
content_type = "application/vnd.android.package-archive"
conn = sqlite3.connect(db_path)
try:
conn.execute("PRAGMA foreign_keys = ON")
conn.execute("BEGIN")
row = conn.execute('SELECT "Id" FROM "Apps" WHERE "Slug" = ?', (slug,)).fetchone()
if row is None:
app_id = str(uuid.uuid4()).upper()
conn.execute(
'''
INSERT INTO "Apps"
("Id", "Slug", "Name", "Summary", "Description", "RepositoryUrl", "HomepageUrl", "IsListed", "CreatedAt", "UpdatedAt")
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(app_id, slug, name, summary, description, repository_url, homepage_url, is_listed, now, now),
)
else:
app_id = row[0]
conn.execute(
'''
UPDATE "Apps"
SET "Name" = ?,
"Summary" = ?,
"Description" = ?,
"RepositoryUrl" = ?,
"HomepageUrl" = ?,
"IsListed" = ?,
"UpdatedAt" = ?
WHERE "Id" = ?
''',
(name, summary, description, repository_url, homepage_url, is_listed, now, app_id),
)
duplicate = conn.execute(
'''
SELECT "Id"
FROM "Releases"
WHERE "CatalogAppId" = ? AND "Version" = ? AND "Channel" = ? AND "Platform" = ?
''',
(app_id, version, channel, platform),
).fetchone()
if duplicate is not None:
raise RuntimeError(f"Release already exists for {slug} {version} {channel} {platform}.")
conn.execute(
'''
INSERT INTO "Releases"
("Id", "CatalogAppId", "Version", "Channel", "Platform", "PackageKind",
"OriginalFileName", "StoredRelativePath", "ContentType", "PackageSizeBytes",
"Sha256", "Notes", "PublishedAt")
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''',
(
release_id,
app_id,
version,
channel,
platform,
package_kind,
source.name,
stored_relative_path,
content_type,
size_bytes,
sha256,
notes,
now,
),
)
conn.commit()
except Exception:
conn.rollback()
try:
target_path.unlink()
except FileNotFoundError:
pass
raise
finally:
conn.close()
print(f"Published app={slug} version={version} channel={channel} platform={platform}")
print(f"StoredRelativePath={stored_relative_path}")
print(f"Size={size_bytes}")
print(f"Sha256={sha256}")
print(f"Manifest=$ArgusBaseUrl/api/apps/{slug}/manifest?platform={platform}&channel={channel}")
PY
curl -sS "http://127.0.0.1:5105/api/apps/`$ARGUS_SLUG/manifest?platform=`$ARGUS_PLATFORM&channel=`$ARGUS_CHANNEL" >/dev/null
"@
$remoteScript | & ssh "$sshTarget" "bash -s"
Assert-LastExitCode "Publish release on Argus host"
$manifest = Invoke-RestMethod -Uri $manifestUrl -Headers @{ Accept = "application/json" }
if ($manifest.release.version -ne $Version) {
throw "Argus manifest verification failed: expected version $Version, got $($manifest.release.version)."
}
function Read-ResponseBody($Response) {
return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
function Assert-Success($Response, [string]$Action) {
if (-not $Response.IsSuccessStatusCode) {
$body = Read-ResponseBody $Response
throw "$Action failed: HTTP $([int]$Response.StatusCode) $body"
}
}
try {
$loginPayload = @{
username = $Username
password = $Password
}
$loginResponse = $client.PostAsync("/api/admin/session/login", (New-JsonContent $loginPayload)).GetAwaiter().GetResult()
Assert-Success $loginResponse "Argus login"
$metadataPayload = @{
name = $Name
summary = $Summary
description = $Description
androidPackageName = $AndroidPackageName
repositoryUrl = $null
homepageUrl = $null
isListed = $true
}
$metadataResponse = $client.PutAsync("/api/admin/apps/$slugEscaped", (New-JsonContent $metadataPayload)).GetAwaiter().GetResult()
Assert-Success $metadataResponse "Argus app metadata update"
$form = [System.Net.Http.MultipartFormDataContent]::new()
$form.Add([System.Net.Http.StringContent]::new($Version), "Version")
$form.Add([System.Net.Http.StringContent]::new($Channel), "Channel")
$form.Add([System.Net.Http.StringContent]::new($Platform), "Platform")
$form.Add([System.Net.Http.StringContent]::new($PackageKind), "PackageKind")
$form.Add([System.Net.Http.StringContent]::new($AndroidVersionCode.ToString()), "AndroidVersionCode")
$form.Add([System.Net.Http.StringContent]::new($Notes), "Notes")
$fileStream = [IO.File]::OpenRead($resolvedApk)
$fileContent = [System.Net.Http.StreamContent]::new($fileStream)
$fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse("application/vnd.android.package-archive")
$form.Add($fileContent, "Package", [IO.Path]::GetFileName($resolvedApk))
$releaseResponse = $client.PostAsync("/api/admin/apps/$slugEscaped/releases", $form).GetAwaiter().GetResult()
Assert-Success $releaseResponse "Argus release upload"
$releaseBody = Read-ResponseBody $releaseResponse
Write-Output $releaseBody
} finally {
if ($fileStream) {
$fileStream.Dispose()
}
if ($form) {
$form.Dispose()
}
$client.Dispose()
$handler.Dispose()
}
Write-Output "Published $Slug $Version to $manifestUrl"
+22 -9
View File
@@ -23,12 +23,19 @@ Date: 2026-06-08.
- Android release builds now fail fast when release signing variables are missing, so unsigned release artifacts cannot be published by accident.
- Android version was raised to `1.3.12` with `versionCode` `40`.
- Android now has QSfera-specific Argus publishing scripts: local upload-keystore generation, signed APK staging, APK metadata verification, debug-certificate rejection, and Argus upload.
- Android Argus staging now enforces release git provenance by default: the work tree must be clean, the branch must have an upstream, `git fetch` must succeed, and `HEAD` must match `@{u}` before a stable APK is prepared for upload. The generated `argus-publication.json` records the verified branch, upstream, commit, and timestamp.
- Android version was raised to `1.3.13` with `versionCode` `41` for the next Argus publication.
- Android version was raised to `1.3.14` with `versionCode` `42` for the updated SSH-only Argus publication flow.
- Android now has QSfera-specific Argus publishing scripts: local upload-keystore generation, signed APK staging, APK metadata verification, debug-certificate rejection, and SSH-only Argus publication through the `/srv/argus-data/argus.db` and `/srv/argus-data/Packages` workflow required by `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`; HTTP `/api/admin/*` publication is not used.
- Android Argus update and publication were realigned with `G:/Repos/Marketplace/ARGUS_PUBLICATION.md` on 2026-06-13: `Android/scripts/publish-release.ps1` now generates and runs the SSH/SQLite/Data/Packages server-side publication block, and `AppUpdateManager` uses only the public read-only manifest contract for update discovery.
- Android now has `Android/scripts/verify-release-signing-material.ps1` to verify local release signing properties, keystore readability, release certificate SHA-256, and optionally the latest published Argus APK certificate without printing signing secrets.
- Android Argus staging now enforces release git provenance by default: the work tree must be clean, the branch must have an upstream, `git fetch` must succeed, and `HEAD` must match `@{u}` before a stable APK is prepared for upload. The generated `argus-publication.json` records the verified repository root, branch, upstream, commit, and timestamp; current monorepo publication can verify the pushed root `QSfera.git` branch by running from `Android/` with `-GitProvenanceRoot ..`.
- Android `1.3.9`/`37` was published to Argus as `qsfera-mobile` on 2026-06-10. Argus upload returned release id `53ecc24e-106b-43a5-a39e-ffacd72a43f8`, package size `12226572`, and SHA-256 `cbd2c25328e395ea4da4e0f582feac39dec18d2d47b3e105d344904d8a24f017`.
- Android `1.3.10`/`38` was published to Argus as `qsfera-mobile` on 2026-06-10. Argus upload returned release id `8465ba6d-a008-45f5-9c70-e6cd2c94317a`, package size `12228464`, and SHA-256 `ce33a73ce122de6c9c797e7cb552e5493b628c60ddc98fda6a9e230e3c1ed7fe`.
- Android `1.3.11`/`39` was published to Argus as `qsfera-mobile` on 2026-06-10. Argus upload returned release id `3dbac5ad-b19c-46e6-b24f-8f22e1c466dd`, package size `12234884`, and SHA-256 `c9eae41bbaae421e1a69721e891d8431ce599684e0f655b71693a422aa83720a`.
- Android `1.3.12`/`40` was published to Argus as `qsfera-mobile` on 2026-06-10. Argus upload returned release id `615b58c5-b5f7-4a2a-b6de-03f821eb43a4`, package size `12237552`, and SHA-256 `279d00c6a43072caa0b26269f547fb0dc3268088aae5cd9f4ccfbbb485c35ded`.
- Android `1.3.13`/`41` was published to Argus as `qsfera-mobile` on 2026-06-12. Argus upload returned release id `39072ada-7557-48f3-ab42-ad68703d00b0`, package size `12261408`, and SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`.
- Android `1.3.14`/`42` was published to Argus as `qsfera-mobile` on 2026-06-13 through the SSH/SQLite/Data/Packages workflow from `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`. The server-side publication block returned release id `4394333f-a84a-4a3b-9bc7-6a48d054d4fe`, stored path `qsfera-mobile/20260613170521-1.3.14-4394333FA84A4A3B9BC76A48D054D4FE.apk`, package size `12262908`, and SHA-256 `8ddfbb9d3dbff3f7bc1030024a0f03a7928771b06cb373c8996c96fb22ad5a18`.
- `Android/scripts/verify-release-signing-material.ps1 -VerifyPublishedApk` passed on 2026-06-12: local `Android/argus-signing.local.properties` and `Android/signing/argus-upload.jks` are complete/readable, alias `argus-upload` exists, and both the keystore certificate and the latest published Argus APK report certificate SHA-256 `ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877`.
- Android auto-upload source paths are normalized and new/first source folders start from timestamp `0`, so existing media is eligible for initial backup.
- Android auto-upload validates persisted read permission and `DocumentFile.canRead()` before scanning SAF folders.
- Android immediate auto-upload scheduling no longer blocks the caller while waiting for WorkManager state.
@@ -46,6 +53,8 @@ Date: 2026-06-08.
- Server `qsfera_full` compose files now require explicit admin, Keycloak, and S3/MinIO secrets instead of demo defaults.
- Server `qsfera_full/env.production.example` provides a tracked production-safe starting point with TLS validation enabled and demo users disabled.
- Server `qsfera_full/validate-production-env.sh` checks production `.env` invariants before compose validation: TLS validation enabled, demo users disabled, pinned production image, required domains, required secrets, and no public-production MinIO.
- Added `scripts/validate-server-production-env.ps1` with the same `qsfera_full` production `.env` checks for the local Windows/PowerShell workflow; it validates required domains, release image pinning, disabled demo/insecure modes, required secrets, optional Keycloak/S3 blocks, `COMPOSE_PATH_SEPARATOR`, selected `COMPOSE_FILE` fragments, and backup-friendly config/data path warnings without printing secret values.
- `scripts/validate-server-production-env.ps1` was verified on 2026-06-12: it rejects `Server/devtools/deployments/qsfera_full/env.production.example` with placeholder values, accepts a temporary filled production-shaped env file with `0 warning(s)`, and rejects a temporary env file where `COMPOSE_FILE` omits the required `qsfera.yml` fragment.
- Server now has a Raspberry Pi 5 Docker path matching the Ikar/Argus deployment style: `Server/docker-compose.rpi5.yml`, `Server/.env.example`, and `Server/docs/raspberry-pi-5-docker.md`.
- Server Docker builds now accept explicit production metadata for `QSFERA_VERSION`, `QSFERA_EDITION`, and `QSFERA_BUILD_DATE`, and the Raspberry Pi 5 compose path defaults to pinned image `qsfera-cloud-server:7.0.0-rpi5-stable`.
- Windows/Desktop update channel defaults now match the build channel: stable builds default to `stable`, beta builds default to `beta`.
@@ -54,8 +63,12 @@ Date: 2026-06-08.
- 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.
- Windows Desktop packaging now fails before ZIP/MSI creation if the staged app is missing critical Qt6/MSVC/OpenSSL DLLs, the Qt platform plugin, QSfera VFS plugins, QML import manifests, or `qt.conf` runtime path entries; it also smoke-starts the staged `qsfera.exe --version` from the staged working directory.
- 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; the Android gate now pins the stable release id, original APK filename, content type, package size, download path, and SHA-256.
- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-10: server `edition=stable`/`productversion=7.0.0`, Android `1.3.12`/`40`, downloaded APK size `12237552`, APK SHA-256 `279d00c6a43072caa0b26269f547fb0dc3268088aae5cd9f4ccfbbb485c35ded`, and valid Desktop no-update feed.
- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-12: server `edition=stable`/`productversion=7.0.0`, Android release id `39072ada-7557-48f3-ab42-ad68703d00b0`, Android `1.3.13`/`41`, APK filename `qsfera-mobile-41.apk`, content type `application/vnd.android.package-archive`, downloaded APK size `12261408`, APK SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`, and valid Desktop no-update feed.
- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-13 against the current public Argus manifest contract: server `edition=stable`/`productversion=7.0.0`, Android release id `39072ada-7557-48f3-ab42-ad68703d00b0`, Android version `1.3.13`, APK filename `qsfera-mobile-41.apk`, content type `application/vnd.android.package-archive`, downloaded APK size `12261408`, APK SHA-256 `f065e6584e6a09999e72ba0eeeaae903e93fb8980a0426e4b39b5e2366bf6611`, and valid Desktop no-update feed. The live manifest currently omits `androidPackageName` and `androidVersionCode`; the verifier treats both as optional unless strict switches are set.
- `scripts/verify-production-state.ps1 -VerifyAndroidDownload` passed on 2026-06-13 after publishing Android `1.3.14`: server `edition=stable`/`productversion=7.0.0`, Android release id `4394333f-a84a-4a3b-9bc7-6a48d054d4fe`, Android version `1.3.14`, APK filename `qsfera-mobile-42.apk`, content type `application/vnd.android.package-archive`, downloaded APK size `12262908`, APK SHA-256 `8ddfbb9d3dbff3f7bc1030024a0f03a7928771b06cb373c8996c96fb22ad5a18`, 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.
- Added systemd monitoring units for the Pi: `Server/systemd/qsfera-rpi-production-check.service`, `Server/systemd/qsfera-rpi-production-check.timer`, `Server/systemd/qsfera-rpi-production-check-alert@.service`, `Server/systemd/rpi-production-check.env.example`, plus installer `Server/scripts/install-rpi-production-monitoring.sh` and alert helper `Server/scripts/alert-rpi-production-check-failure.sh`.
- Deployed the Pi metrics/checker update on 2026-06-09. Before upload, `/mnt/data/qsfera/cloud-server/src/docker-compose.rpi5.yml` was backed up to `/mnt/data/qsfera/backups/docker-compose.rpi5.yml-20260609-181614.bak`; `docker compose -f docker-compose.rpi5.yml up -d` recreated and started `qsfera-cloud-server`; `docker compose ps` reported loopback ports `127.0.0.1:9200->9200/tcp` and `127.0.0.1:9205->9205/tcp`.
@@ -76,19 +89,19 @@ Date: 2026-06-08.
## Android Argus Publication Check
- Current public Argus manifest for `https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable` reports release id `615b58c5-b5f7-4a2a-b6de-03f821eb43a4`, version `1.3.12`, channel `stable`, platform `android`, package kind `apk`, `androidVersionCode` `40`, package size `12237552`, and SHA-256 `279d00c6a43072caa0b26269f547fb0dc3268088aae5cd9f4ccfbbb485c35ded`.
- Independent download verification of `https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable` produced a `12237552` byte APK with the same SHA-256 `279d00c6a43072caa0b26269f547fb0dc3268088aae5cd9f4ccfbbb485c35ded`.
- Current public Argus manifest for `https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable` reports release id `4394333f-a84a-4a3b-9bc7-6a48d054d4fe`, version `1.3.14`, channel `stable`, platform `android`, package kind `apk`, original file name `qsfera-mobile-42.apk`, content type `application/vnd.android.package-archive`, package size `12262908`, and SHA-256 `8ddfbb9d3dbff3f7bc1030024a0f03a7928771b06cb373c8996c96fb22ad5a18`. It does not currently include legacy Android-specific root `androidPackageName` or release `androidVersionCode` fields.
- Independent download verification of `https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable` produced a `12262908` byte APK with the same SHA-256 `8ddfbb9d3dbff3f7bc1030024a0f03a7928771b06cb373c8996c96fb22ad5a18`.
- The previously published `1.3.5`/`33` APK matched Argus SHA-256 `b7d5bdcb49527c00983a15849252fa42189dee4287754803c46cf2f32e2130f3`, but `apksigner` reported `CN=Android Debug` with certificate SHA-256 `b86d745cecb01ca24bda03092fe94ca7801678513b97edd1175bcabfdea4e941`.
- The new `1.3.12`/`40` APK is signed by `CN=QSfera Argus Upload, O=QSfera, C=RU` with certificate SHA-256 `ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877`.
- `AppUpdateManager` validates downloaded APK package name, greater `versionCode`, Argus `androidVersionCode`, and signer digest against the installed app signature. Because of that local code path, devices that installed debug-signed `1.3.5` should be treated as needing reinstall or an explicit migration plan before they can move to the current production-signed stable APK.
- The current production-signed `1.3.14`/`42` APK is signed by `CN=QSfera Argus Upload, O=QSfera, C=RU` with certificate SHA-256 `ea5c304c78ee333f6586f54c933d986c1f865da9c4e3595f8fe0df274c1ab877`.
- `AppUpdateManager` follows the public read-only Argus manifest contract from `G:/Repos/Marketplace/ARGUS_PUBLICATION.md`: HTTP `404` is treated as no update, `release.version` is compared with the installed version, downloads use `ARGUS_BASE_URL + release.downloadPath`, `release.packageSizeBytes` and `release.sha256` are verified before install, and the downloaded APK package name/version/signature are checked against the installed app. Because of that local code path, devices that installed debug-signed `1.3.5` should be treated as needing reinstall or an explicit migration plan before they can move to the current production-signed stable APK.
- The rollout policy for debug-signed `1.3.5` devices is documented in `Android/docs/argus-rollout-policy.md`: the app does not bypass signer mismatch; users must back up unsynced local files, uninstall the debug-signed build, then install the current stable APK from Argus. The Android app now shows a reinstall-required dialog for this certificate-mismatch path.
## Remaining P0
- Back up the gitignored Android release signing files created under `Android/signing/` and `Android/argus-signing.local.properties` in a secure external secret store. Without this key material, future in-place Android updates from the production-signed `1.3.12` cannot be signed with the same certificate.
- Back up the gitignored Android release signing files created under `Android/signing/` and `Android/argus-signing.local.properties` in a secure external secret store. Without this key material, future in-place Android updates from the production-signed `1.3.14` cannot be signed with the same certificate.
- Provide CI/Argus signing secrets derived from the same release key: `QSFERA_RELEASE_KEYSTORE`, `QSFERA_RELEASE_KEYSTORE_PASSWORD`, `QSFERA_RELEASE_KEY_ALIAS`, and `QSFERA_RELEASE_KEY_PASSWORD`.
- 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.
- Run `./validate-production-env.sh .env` or `.\scripts\validate-server-production-env.ps1 -EnvPath ...` 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. 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>`.
+60
View File
@@ -63,6 +63,64 @@ function Copy-DirectoryIfPresent([string]$Source, [string]$Destination) {
Copy-Item -Path (Join-Path $Source "*") -Destination $Destination -Recurse -Force
}
function Assert-StageFile([string]$StageDirectory, [string]$RelativePath) {
$path = Join-Path $StageDirectory $RelativePath
if (-not (Test-Path -LiteralPath $path)) {
Fail "Packaged runtime file is missing: $path"
}
if ((Get-Item -LiteralPath $path).Length -le 0) {
Fail "Packaged runtime file is empty: $path"
}
}
function Assert-WindowsRuntimeStage([string]$StageDirectory) {
$criticalFiles = @(
"qsfera.exe",
"qt.conf",
"Qt6Core.dll",
"Qt6Gui.dll",
"Qt6Widgets.dll",
"Qt6Network.dll",
"Qt6Qml.dll",
"Qt6Quick.dll",
"Qt6QuickControls2.dll",
"qt6keychain.dll",
"kdsingleapplication-qt6.dll",
"libcrypto-3-x64.dll",
"libssl-3-x64.dll",
"plugins\platforms\qwindows.dll",
"plugins\tls\qschannelbackend.dll",
"plugins\QSfera_vfs_cfapi.dll",
"plugins\QSfera_vfs_off.dll",
"qml\eu\QSfera\gui\qmldir",
"qml\QtQuick\Controls\qmldir"
)
foreach ($file in $criticalFiles) {
Assert-StageFile -StageDirectory $StageDirectory -RelativePath $file
}
$qtConf = Get-Content -LiteralPath (Join-Path $StageDirectory "qt.conf") -Raw -Encoding ASCII
foreach ($entry in @("Plugins=plugins", "Qml2Imports=qml", "Translations=translations")) {
if (-not $qtConf.Contains($entry)) {
Fail "qt.conf does not contain required entry '$entry'."
}
}
Push-Location $StageDirectory
try {
$versionOutput = & (Join-Path $StageDirectory "qsfera.exe") --version 2>&1
if ($LASTEXITCODE -ne 0) {
Fail "Packaged qsfera.exe failed to start from the staging directory. Output: $($versionOutput | Out-String)"
}
} finally {
Pop-Location
}
Write-Output "OK: packaged Windows runtime contains required Qt/MSVC/OpenSSL files and qsfera.exe --version starts from the staging directory."
}
function ConvertTo-WixIdentifier([string]$Prefix, [string]$Value) {
$normalized = ($Value -replace '[^A-Za-z0-9_]', '_')
if ($normalized.Length -gt 60) {
@@ -312,6 +370,8 @@ Qml2Imports=qml
Translations=translations
"@ | Set-Content -LiteralPath (Join-Path $StageDirectory "qt.conf") -Encoding ASCII
Assert-WindowsRuntimeStage -StageDirectory $StageDirectory
Remove-PathWithRetry $zipPath
Compress-ArchiveWithRetry -SourcePath (Join-Path $StageDirectory "*") -DestinationPath $zipPath
+190
View File
@@ -0,0 +1,190 @@
param(
[string]$EnvPath = (Join-Path $PSScriptRoot "..\Server\devtools\deployments\qsfera_full\.env"),
[switch]$AllowWarnings
)
$ErrorActionPreference = "Stop"
$errors = New-Object System.Collections.Generic.List[string]
$warnings = New-Object System.Collections.Generic.List[string]
function Add-Error([string]$Message) {
[void]$script:errors.Add($Message)
Write-Error $Message -ErrorAction Continue
}
function Add-WarningMessage([string]$Message) {
[void]$script:warnings.Add($Message)
Write-Warning $Message
}
function Read-EnvFile([string]$Path) {
if (-not (Test-Path -LiteralPath $Path)) {
throw "Env file not found: $Path"
}
$result = @{}
foreach ($line in Get-Content -LiteralPath $Path) {
$trimmed = $line.Trim()
if ($trimmed.Length -eq 0 -or $trimmed.StartsWith("#")) {
continue
}
$separator = $trimmed.IndexOf("=")
if ($separator -le 0) {
continue
}
$key = $trimmed.Substring(0, $separator).Trim()
$value = $trimmed.Substring($separator + 1).Trim()
$value = $value.Trim('"').Trim("'")
$result[$key] = $value
}
return $result
}
function Get-EnvValue([hashtable]$Values, [string]$Key) {
if ($Values.ContainsKey($Key)) {
return [string]$Values[$Key]
}
return ""
}
function Test-Placeholder([string]$Value) {
if ([string]::IsNullOrWhiteSpace($Value)) {
return $true
}
if ($Value.StartsWith("<") -and $Value.EndsWith(">")) {
return $true
}
return $Value -in @("change-me", "changeme", "password", "secret") -or $Value.Contains("example.com")
}
function Test-Enabled([hashtable]$Values, [string]$Key) {
return -not [string]::IsNullOrWhiteSpace((Get-EnvValue $Values $Key))
}
function Require-Value([hashtable]$Values, [string]$Key, [string]$Label = $Key) {
$value = Get-EnvValue $Values $Key
if (Test-Placeholder $value) {
Add-Error "$Label must be set to a production value."
}
}
function Require-Secret([hashtable]$Values, [string]$Key) {
$value = Get-EnvValue $Values $Key
if (Test-Placeholder $value) {
Add-Error "$Key must be set."
return
}
if ($value -in @("admin", "demo", "keycloak", "qsfera", "qsfera-secret-key")) {
Add-Error "$Key uses a known demo/default value."
return
}
if ($value.Length -lt 16) {
Add-Error "$Key must be at least 16 characters."
}
}
function Require-ExactValue([hashtable]$Values, [string]$Key, [string]$ExpectedValue) {
$value = Get-EnvValue $Values $Key
if ($value -ne $ExpectedValue) {
Add-Error "$Key must be $ExpectedValue."
}
}
function Test-ComposeFileIncludes([string]$ComposeFile, [string]$EnvKey, [string]$Fragment) {
if ([string]::IsNullOrWhiteSpace($ComposeFile)) {
return $false
}
return $ComposeFile.Contains($Fragment) -or $ComposeFile.Contains('${' + $EnvKey + ':-}')
}
$resolvedEnvPath = (Resolve-Path -LiteralPath $EnvPath -ErrorAction Stop).Path
$values = Read-EnvFile $resolvedEnvPath
Require-Value $values "OC_DOMAIN"
Require-Value $values "TRAEFIK_ACME_MAIL"
Require-Secret $values "ADMIN_PASSWORD"
if ((Get-EnvValue $values "INSECURE") -in @("true", "TRUE", "1", "yes", "YES")) {
Add-Error "INSECURE must be false for production."
}
if ((Get-EnvValue $values "DEMO_USERS") -in @("true", "TRUE", "1", "yes", "YES")) {
Add-Error "DEMO_USERS must be false for production."
}
switch (Get-EnvValue $values "OC_DOCKER_IMAGE") {
"qsfera/qsfera" { }
"" { Add-WarningMessage "OC_DOCKER_IMAGE is empty; compose default must be verified before rollout." }
default { Add-Error "OC_DOCKER_IMAGE must be qsfera/qsfera for production." }
}
$dockerTag = Get-EnvValue $values "OC_DOCKER_TAG"
if ([string]::IsNullOrWhiteSpace($dockerTag) -or $dockerTag -eq "latest" -or ($dockerTag.StartsWith("<") -and $dockerTag.EndsWith(">"))) {
Add-Error "OC_DOCKER_TAG must pin a concrete production release tag."
}
Require-ExactValue $values "COMPOSE_PATH_SEPARATOR" ":"
$composeFile = Get-EnvValue $values "COMPOSE_FILE"
if ([string]::IsNullOrWhiteSpace($composeFile) -or -not $composeFile.Contains("docker-compose.yml")) {
Add-Error "COMPOSE_FILE must include docker-compose.yml."
}
if (-not (Test-Enabled $values "QSFERA")) {
Add-Error "QSFERA=:qsfera.yml must be enabled."
} else {
Require-ExactValue $values "QSFERA" ":qsfera.yml"
if (-not (Test-ComposeFileIncludes $composeFile "QSFERA" "qsfera.yml")) {
Add-Error "COMPOSE_FILE must include qsfera.yml or the `${QSFERA:-} expansion."
}
}
if (Test-Enabled $values "DECOMPOSEDS3") {
Require-ExactValue $values "DECOMPOSEDS3" ":decomposeds3.yml"
if (-not (Test-ComposeFileIncludes $composeFile "DECOMPOSEDS3" "decomposeds3.yml")) {
Add-Error "COMPOSE_FILE must include decomposeds3.yml or the `${DECOMPOSEDS3:-} expansion when DECOMPOSEDS3 is enabled."
}
Require-Value $values "DECOMPOSEDS3_ENDPOINT"
Require-Value $values "DECOMPOSEDS3_REGION"
Require-Secret $values "DECOMPOSEDS3_ACCESS_KEY"
Require-Secret $values "DECOMPOSEDS3_SECRET_KEY"
Require-Value $values "DECOMPOSEDS3_BUCKET"
}
if (Test-Enabled $values "DECOMPOSEDS3_MINIO") {
Add-Error "DECOMPOSEDS3_MINIO is for test/lab installs and must not be enabled for public production."
if (-not (Test-ComposeFileIncludes $composeFile "DECOMPOSEDS3_MINIO" "minio.yml")) {
Add-Error "COMPOSE_FILE must include minio.yml or the `${DECOMPOSEDS3_MINIO:-} expansion when DECOMPOSEDS3_MINIO is enabled."
}
}
if (Test-Enabled $values "KEYCLOAK") {
Require-ExactValue $values "KEYCLOAK" ":keycloak.yml"
if (-not (Test-ComposeFileIncludes $composeFile "KEYCLOAK" "keycloak.yml")) {
Add-Error "COMPOSE_FILE must include keycloak.yml or the `${KEYCLOAK:-} expansion when KEYCLOAK is enabled."
}
Require-Value $values "KEYCLOAK_DOMAIN"
Require-Value $values "KEYCLOAK_REALM"
Require-Secret $values "KEYCLOAK_POSTGRES_PASSWORD"
Require-Value $values "KEYCLOAK_ADMIN_USER"
Require-Secret $values "KEYCLOAK_ADMIN_PASSWORD"
}
if ([string]::IsNullOrWhiteSpace((Get-EnvValue $values "OC_CONFIG_DIR")) -or [string]::IsNullOrWhiteSpace((Get-EnvValue $values "OC_DATA_DIR"))) {
Add-WarningMessage "OC_CONFIG_DIR and OC_DATA_DIR are not both set; docker volumes are harder to back up and restore."
}
if ($errors.Count -gt 0) {
throw "Production env validation failed: $($errors.Count) error(s), $($warnings.Count) warning(s)."
}
if (-not $AllowWarnings -and $warnings.Count -gt 0) {
throw "Production env validation passed with $($warnings.Count) warning(s). Re-run with -AllowWarnings if this is expected."
}
Write-Output "Production env validation passed: $($warnings.Count) warning(s)."
+31 -7
View File
@@ -6,12 +6,18 @@ param(
[string]$AndroidManifestUrl = "https://argus.kusoft.xyz/api/apps/qsfera-mobile/manifest?platform=android&channel=stable",
[string]$ExpectedAndroidSlug = "qsfera-mobile",
[string]$ExpectedAndroidPackageName = "eu.qsfera.android",
[string]$ExpectedAndroidVersion = "1.3.12",
[int]$ExpectedAndroidVersionCode = 40,
[switch]$RequireAndroidPackageName,
[string]$ExpectedAndroidVersion = "1.3.14",
[int]$ExpectedAndroidVersionCode = 42,
[switch]$RequireAndroidVersionCode,
[string]$ExpectedAndroidChannel = "stable",
[string]$ExpectedAndroidPlatform = "android",
[string]$ExpectedAndroidPackageKind = "apk",
[string]$ExpectedAndroidSha256 = "279d00c6a43072caa0b26269f547fb0dc3268088aae5cd9f4ccfbbb485c35ded",
[string]$ExpectedAndroidReleaseId = "4394333f-a84a-4a3b-9bc7-6a48d054d4fe",
[string]$ExpectedAndroidOriginalFileName = "qsfera-mobile-42.apk",
[string]$ExpectedAndroidContentType = "application/vnd.android.package-archive",
[int64]$ExpectedAndroidPackageSizeBytes = 12262908,
[string]$ExpectedAndroidSha256 = "8ddfbb9d3dbff3f7bc1030024a0f03a7928771b06cb373c8996c96fb22ad5a18",
[string]$AndroidDownloadUrl = "https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable",
[switch]$VerifyAndroidDownload,
@@ -104,7 +110,14 @@ Write-Host "Checking QSfera Android Argus manifest..."
$manifestResponse = Invoke-CheckedWebRequest "Android Argus manifest" $AndroidManifestUrl
$manifest = Convert-JsonContent "Android Argus manifest" $manifestResponse.Content
Assert-Equal "Android app slug" (Get-RequiredProperty $manifest "slug" "Android manifest") $ExpectedAndroidSlug
Assert-Equal "Android package name" (Get-RequiredProperty $manifest "androidPackageName" "Android manifest") $ExpectedAndroidPackageName
$androidPackageNameProperty = $manifest.PSObject.Properties["androidPackageName"]
if ($null -ne $androidPackageNameProperty) {
Assert-Equal "Android package name" $androidPackageNameProperty.Value $ExpectedAndroidPackageName
} elseif ($RequireAndroidPackageName) {
Fail "Android manifest is missing 'androidPackageName'."
} else {
Write-Host "OK: Android package name is absent; public Argus contract does not require it"
}
$release = Get-RequiredProperty $manifest "release" "Android manifest"
if ($null -eq $release) {
@@ -112,10 +125,22 @@ if ($null -eq $release) {
}
Assert-Equal "Android release version" (Get-RequiredProperty $release "version" "Android release") $ExpectedAndroidVersion
Assert-Equal "Android versionCode" (Get-RequiredProperty $release "androidVersionCode" "Android release") $ExpectedAndroidVersionCode
$androidVersionCodeProperty = $release.PSObject.Properties["androidVersionCode"]
if ($null -ne $androidVersionCodeProperty) {
Assert-Equal "Android versionCode" $androidVersionCodeProperty.Value $ExpectedAndroidVersionCode
} elseif ($RequireAndroidVersionCode) {
Fail "Android release is missing 'androidVersionCode'."
} else {
Write-Host "OK: Android versionCode is absent; public Argus contract does not require it"
}
Assert-Equal "Android release channel" (Get-RequiredProperty $release "channel" "Android release") $ExpectedAndroidChannel
Assert-Equal "Android release platform" (Get-RequiredProperty $release "platform" "Android release") $ExpectedAndroidPlatform
Assert-Equal "Android package kind" (Get-RequiredProperty $release "packageKind" "Android release") $ExpectedAndroidPackageKind
Assert-Equal "Android release id" (Get-RequiredProperty $release "id" "Android release") $ExpectedAndroidReleaseId
Assert-Equal "Android original file name" (Get-RequiredProperty $release "originalFileName" "Android release") $ExpectedAndroidOriginalFileName
Assert-Equal "Android content type" (Get-RequiredProperty $release "contentType" "Android release") $ExpectedAndroidContentType
Assert-Equal "Android package size" (Get-RequiredProperty $release "packageSizeBytes" "Android release") $ExpectedAndroidPackageSizeBytes
Assert-Equal "Android release download path" (Get-RequiredProperty $release "downloadPath" "Android release") "/api/apps/$ExpectedAndroidSlug/releases/$ExpectedAndroidReleaseId/download"
$releaseSha256 = Get-RequiredProperty $release "sha256" "Android release"
Assert-True "Android SHA-256 shape" (Test-Sha256String $releaseSha256)
Assert-Equal "Android SHA-256" $releaseSha256 $ExpectedAndroidSha256
@@ -132,8 +157,7 @@ if ($VerifyAndroidDownload) {
}
$downloadSize = (Get-Item -LiteralPath $tempFile).Length
$expectedSize = Get-RequiredProperty $release "packageSizeBytes" "Android release"
Assert-Equal "Android APK size" $downloadSize $expectedSize
Assert-Equal "Android APK size" $downloadSize $ExpectedAndroidPackageSizeBytes
$downloadSha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $tempFile).Hash.ToLowerInvariant()
Assert-Equal "Android APK download SHA-256" $downloadSha256 $ExpectedAndroidSha256