Prepare QSfera production release

This commit is contained in:
Курнат Андрей
2026-06-08 19:57:40 +03:00
parent 2315f25754
commit 43caef7a6a
27 changed files with 1159 additions and 42 deletions
+4
View File
@@ -12,6 +12,10 @@
# Local configuration files (sdk path, etc)
local.properties
# Local Argus release signing secrets
argus-signing.local.properties
signing/
# Mac .DS_Store files
.DS_Store
+28 -2
View File
@@ -14,6 +14,18 @@ def envValue(String primary, String legacy = null) {
return legacy ? System.getenv(legacy) : null
}
def releaseSigningEnv = [
[primary: 'QSFERA_RELEASE_KEYSTORE', legacy: 'OC_RELEASE_KEYSTORE'],
[primary: 'QSFERA_RELEASE_KEYSTORE_PASSWORD', legacy: 'OC_RELEASE_KEYSTORE_PASSWORD'],
[primary: 'QSFERA_RELEASE_KEY_ALIAS', legacy: 'OC_RELEASE_KEY_ALIAS'],
[primary: 'QSFERA_RELEASE_KEY_PASSWORD', legacy: 'OC_RELEASE_KEY_PASSWORD'],
]
def missingReleaseSigningEnvKeys = {
releaseSigningEnv.findAll { !envValue(it.primary, it.legacy) }
.collect { "${it.primary} (${it.legacy} legacy fallback)" }
}
dependencies {
// Data and domain modules
implementation project(':qsferaDomain')
@@ -123,8 +135,8 @@ android {
testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner"
versionCode = 32
versionName = "1.3.4"
versionCode = 34
versionName = "1.3.6"
buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\""
buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\""
@@ -235,6 +247,20 @@ android {
testNamespace "eu.qsfera.android.test"
}
gradle.taskGraph.whenReady { taskGraph ->
def releaseBuildRequested = taskGraph.allTasks.any { task ->
def taskName = task.name.toLowerCase()
taskName.endsWith("release") &&
(taskName.startsWith("assemble") || taskName.startsWith("bundle") || taskName.startsWith("package"))
}
def missingSigningEnvKeys = missingReleaseSigningEnvKeys()
if (releaseBuildRequested && !missingSigningEnvKeys.isEmpty()) {
throw new GradleException(
"Release builds must be signed. Missing env vars: ${missingSigningEnvKeys.join(', ')}"
)
}
}
// Updates output file names of a given variant to format
// [appName].[variant.versionName].[QSFERA_BUILD_NUMBER]-[variant.name].apk.
//
@@ -30,6 +30,7 @@ import eu.qsfera.android.R
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.initialSyncTimestamp
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.pictureUploadsName
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
import eu.qsfera.android.domain.automaticuploads.usecases.GetPictureUploadsConfigurationStreamUseCase
@@ -181,7 +182,7 @@ class SettingsPictureUploadsViewModel(
SavePictureUploadsConfigurationUseCase.Params(
composePictureUploadsConfiguration(
sourcePath = encodeSourcePaths(updatedSourcePaths),
timestamp = System.currentTimeMillis().takeIf { updatedSourcePaths != previousSourcePaths }
timestamp = initialSyncTimestamp.takeIf { updatedSourcePaths != previousSourcePaths }
)
)
)
@@ -220,9 +221,9 @@ class SettingsPictureUploadsViewModel(
behavior = behavior ?: UploadBehavior.COPY,
sourcePath = sourcePath.orEmpty(),
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
wifiOnly = wifiOnly ?: false,
wifiOnly = wifiOnly ?: true,
chargingOnly = chargingOnly ?: false,
lastSyncTimestamp = timestamp ?: System.currentTimeMillis(),
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
name = _pictureUploads.value?.name ?: pictureUploadsName,
spaceId = spaceId,
).also {
@@ -30,6 +30,7 @@ import eu.qsfera.android.R
import eu.qsfera.android.db.PreferenceManager.PREF__CAMERA_UPLOADS_DEFAULT_PATH
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.encodeSourcePaths
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.initialSyncTimestamp
import eu.qsfera.android.domain.automaticuploads.model.FolderBackUpConfiguration.Companion.videoUploadsName
import eu.qsfera.android.domain.automaticuploads.model.UploadBehavior
import eu.qsfera.android.domain.automaticuploads.usecases.GetVideoUploadsConfigurationStreamUseCase
@@ -181,7 +182,7 @@ class SettingsVideoUploadsViewModel(
SaveVideoUploadsConfigurationUseCase.Params(
composeVideoUploadsConfiguration(
sourcePath = encodeSourcePaths(updatedSourcePaths),
timestamp = System.currentTimeMillis().takeIf { updatedSourcePaths != previousSourcePaths }
timestamp = initialSyncTimestamp.takeIf { updatedSourcePaths != previousSourcePaths }
)
)
)
@@ -221,9 +222,9 @@ class SettingsVideoUploadsViewModel(
behavior = behavior ?: UploadBehavior.COPY,
sourcePath = sourcePath.orEmpty(),
uploadPath = uploadPath ?: PREF__CAMERA_UPLOADS_DEFAULT_PATH,
wifiOnly = wifiOnly ?: false,
wifiOnly = wifiOnly ?: true,
chargingOnly = chargingOnly ?: false,
lastSyncTimestamp = timestamp ?: System.currentTimeMillis(),
lastSyncTimestamp = timestamp ?: initialSyncTimestamp,
name = _videoUploads.value?.name ?: videoUploadsName,
spaceId = spaceId,
).also {
@@ -42,6 +42,9 @@ import eu.qsfera.android.workers.OldLogsCollectorWorker
import eu.qsfera.android.workers.RemoveLocallyFilesWithLastUsageOlderThanGivenTimeWorker
import eu.qsfera.android.workers.UploadFileFromContentUriWorker
import eu.qsfera.android.workers.UploadFileFromFileSystemWorker
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.TimeUnit
@@ -90,28 +93,30 @@ class WorkManagerProvider(
* concurrent scans or redundant enqueues on rapid foreground/background switches.
*/
fun enqueueImmediateAutomaticUploadsWorker() {
val wm = WorkManager.getInstance(context)
CoroutineScope(Dispatchers.IO).launch {
val wm = WorkManager.getInstance(context)
val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
val immediateRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
val periodicRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.AUTOMATIC_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
val immediateRunning = wm.getWorkInfosForUniqueWork(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.get().any { it.state == WorkInfo.State.RUNNING }
if (periodicRunning || immediateRunning) {
Timber.d("Automatic uploads worker already running, skipping immediate run")
return
if (periodicRunning || immediateRunning) {
Timber.d("Automatic uploads worker already running, skipping immediate run")
return@launch
}
val immediateWorker = OneTimeWorkRequestBuilder<AutomaticUploadsWorker>()
.addTag(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, TimeUnit.MILLISECONDS)
.build()
wm.enqueueUniqueWork(
AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER,
ExistingWorkPolicy.KEEP,
immediateWorker
)
}
val immediateWorker = OneTimeWorkRequestBuilder<AutomaticUploadsWorker>()
.addTag(AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER)
.setInitialDelay(AutomaticUploadsWorker.WRITE_SAFETY_BUFFER_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
.build()
wm.enqueueUniqueWork(
AutomaticUploadsWorker.IMMEDIATE_UPLOADS_WORKER,
ExistingWorkPolicy.KEEP,
immediateWorker
)
}
fun enqueueOldLogsCollectorWorker() {
@@ -128,8 +128,13 @@ class AutomaticUploadsWorker(
private fun checkSourcePathsAreValidUrisOrThrowException(sourcePaths: List<String>) {
sourcePaths.forEach { sourcePath ->
val sourceUri: Uri = sourcePath.toUri()
DocumentFile.fromTreeUri(applicationContext, sourceUri)
val hasPersistedReadPermission = appContext.contentResolver.persistedUriPermissions
.any { uriPermission -> uriPermission.uri == sourceUri && uriPermission.isReadPermission }
val documentTree = DocumentFile.fromTreeUri(applicationContext, sourceUri)
?: throw IllegalArgumentException("Source path is not a valid tree URI: $sourcePath")
if (!hasPersistedReadPermission || !documentTree.canRead()) {
throw IllegalArgumentException("Source path is not readable: $sourcePath")
}
}
}
@@ -38,6 +38,7 @@ data class FolderBackUpConfiguration(
companion object {
const val pictureUploadsName = "Picture uploads"
const val videoUploadsName = "Video uploads"
const val initialSyncTimestamp = 0L
fun parseSourcePaths(sourcePath: String): List<String> =
sourcePath
@@ -0,0 +1,96 @@
param(
[string]$KeystorePath = 'signing/argus-upload.jks',
[string]$PropertiesPath = 'argus-signing.local.properties',
[string]$KeyAlias = 'argus-upload',
[switch]$Force
)
$ErrorActionPreference = 'Stop'
function New-Secret {
$value = ((1..3 | ForEach-Object { [Guid]::NewGuid().ToString('N') }) -join '')
return $value.Substring(0, 32)
}
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 $keytool) {
return $candidate
}
}
throw 'keytool.exe was not found. Set JAVA_HOME to a JDK path.'
}
$repoRoot = Split-Path -Parent $PSScriptRoot
$resolvedKeystorePath = if ([System.IO.Path]::IsPathRooted($KeystorePath)) {
$KeystorePath
} else {
Join-Path $repoRoot $KeystorePath
}
$resolvedPropertiesPath = if ([System.IO.Path]::IsPathRooted($PropertiesPath)) {
$PropertiesPath
} else {
Join-Path $repoRoot $PropertiesPath
}
if ((Test-Path $resolvedKeystorePath) -and (Test-Path $resolvedPropertiesPath) -and -not $Force) {
Write-Output 'Keystore and properties already exist.'
Write-Output "Keystore: $resolvedKeystorePath"
Write-Output "Properties: $resolvedPropertiesPath"
exit 0
}
if (((Test-Path $resolvedKeystorePath) -xor (Test-Path $resolvedPropertiesPath)) -and -not $Force) {
throw 'Keystore and properties are in a partial state. Use -Force to recreate the pair.'
}
$keystoreDirectory = Split-Path -Parent $resolvedKeystorePath
if (-not (Test-Path $keystoreDirectory)) {
New-Item -ItemType Directory -Path $keystoreDirectory | Out-Null
}
$storePassword = New-Secret
$keyPassword = $storePassword
$javaHome = Resolve-JavaHome
$keytoolPath = Join-Path $javaHome 'bin\keytool.exe'
if (Test-Path $resolvedKeystorePath) {
Remove-Item $resolvedKeystorePath -Force
}
& $keytoolPath `
-genkeypair `
-keystore $resolvedKeystorePath `
-storetype PKCS12 `
-storepass $storePassword `
-keypass $keyPassword `
-alias $KeyAlias `
-keyalg RSA `
-keysize 4096 `
-validity 10000 `
-dname 'CN=QSfera Argus Upload, O=QSfera, C=RU' | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "keytool failed with exit code $LASTEXITCODE."
}
$propertiesContent = @(
"QSFERA_RELEASE_KEYSTORE=$resolvedKeystorePath"
"QSFERA_RELEASE_KEYSTORE_PASSWORD=$storePassword"
"QSFERA_RELEASE_KEY_ALIAS=$KeyAlias"
"QSFERA_RELEASE_KEY_PASSWORD=$keyPassword"
) -join [Environment]::NewLine
[System.IO.File]::WriteAllText($resolvedPropertiesPath, $propertiesContent, [System.Text.Encoding]::ASCII)
Write-Output 'Created QSfera Argus upload keystore.'
Write-Output "Keystore: $resolvedKeystorePath"
Write-Output "Properties: $resolvedPropertiesPath"
@@ -0,0 +1,277 @@
param(
[string]$Slug = 'qsfera-mobile',
[string]$Name,
[string]$Summary = 'Android client for QSfera server.',
[string]$Description = 'Android client for QSfera server, prepared for Argus publication.',
[string]$Version,
[string]$Channel = 'stable',
[string]$Platform = 'android',
[string]$Notes,
[string]$RepositoryUrl = 'https://git.kusoft.xyz/sevenhill/QSfera.git',
[string]$HomepageUrl = 'https://qsfera.kusoft.xyz',
[switch]$Unlisted,
[switch]$SkipSigningInitialization
)
$ErrorActionPreference = 'Stop'
function Require-Tool([string]$Path, [string]$Label) {
if (-not (Test-Path $Path)) {
throw "$Label was not found: $Path"
}
}
function Get-ApkBadgingValue([string]$BadgingText, [string]$Pattern) {
$match = [regex]::Match($BadgingText, $Pattern, [System.Text.RegularExpressions.RegexOptions]::Multiline)
if (-not $match.Success) {
return $null
}
return $match.Groups[1].Value
}
function Read-Properties([string]$Path) {
$result = @{}
foreach ($line in Get-Content $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) {
$java = Join-Path $candidate 'bin\java.exe'
if (Test-Path $java) {
return $candidate
}
}
throw 'java.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 (Join-Path $candidate 'build-tools')) {
return $candidate
}
}
throw 'Android SDK was not found. Set ANDROID_HOME to the SDK path.'
}
function Resolve-ArgusPlatform([string[]]$NativeCodes) {
$normalized = $NativeCodes | Where-Object { $_ } | ForEach-Object { $_.Trim().ToLowerInvariant() }
$allUniversalAbis = @('arm64-v8a', 'armeabi-v7a', 'x86', 'x86_64')
if (@($allUniversalAbis | Where-Object { $normalized -contains $_ }).Count -eq $allUniversalAbis.Count) {
return 'android-universal'
}
if ($normalized -contains 'arm64-v8a') {
return 'android-arm64'
}
if ($normalized -contains 'armeabi-v7a') {
return 'android-armv7'
}
if ($normalized -contains 'x86_64') {
return 'android-x86_64'
}
if ($normalized -contains 'x86') {
return 'android-x86'
}
return 'android'
}
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) {
throw "$ToolName was not found under Android build-tools."
}
return $tool.FullName
}
$repoRoot = Split-Path -Parent $PSScriptRoot
Set-Location $repoRoot
$javaHome = Resolve-JavaHome
$androidHome = Resolve-AndroidHome
$env:JAVA_HOME = $javaHome
$env:ANDROID_HOME = $androidHome
$gradlePath = Join-Path $repoRoot 'gradlew.bat'
$aaptPath = Find-FirstTool $androidHome 'aapt.exe'
$apksignerPath = Find-FirstTool $androidHome 'apksigner.bat'
$signingPropertiesPath = Join-Path $repoRoot 'argus-signing.local.properties'
$signingInitScript = Join-Path $PSScriptRoot 'new-argus-upload-keystore.ps1'
Require-Tool $gradlePath 'Gradle wrapper'
Require-Tool $aaptPath 'aapt'
Require-Tool $apksignerPath 'apksigner'
Require-Tool $signingInitScript 'Argus signing init script'
if (-not (Test-Path $signingPropertiesPath)) {
if ($SkipSigningInitialization) {
throw "Signing properties were not found: $signingPropertiesPath"
}
& $signingInitScript
if ($LASTEXITCODE -ne 0) {
throw "Signing initialization failed with exit code $LASTEXITCODE."
}
}
$signingProperties = Read-Properties $signingPropertiesPath
foreach ($key in @(
'QSFERA_RELEASE_KEYSTORE',
'QSFERA_RELEASE_KEYSTORE_PASSWORD',
'QSFERA_RELEASE_KEY_ALIAS',
'QSFERA_RELEASE_KEY_PASSWORD'
)) {
if (-not $signingProperties.ContainsKey($key) -or [string]::IsNullOrWhiteSpace($signingProperties[$key])) {
throw "Signing property is missing: $key"
}
}
$env:QSFERA_RELEASE_KEYSTORE = $signingProperties['QSFERA_RELEASE_KEYSTORE']
$env:QSFERA_RELEASE_KEYSTORE_PASSWORD = $signingProperties['QSFERA_RELEASE_KEYSTORE_PASSWORD']
$env:QSFERA_RELEASE_KEY_ALIAS = $signingProperties['QSFERA_RELEASE_KEY_ALIAS']
$env:QSFERA_RELEASE_KEY_PASSWORD = $signingProperties['QSFERA_RELEASE_KEY_PASSWORD']
& $gradlePath :qsferaApp:assembleOriginalRelease --console=plain
if ($LASTEXITCODE -ne 0) {
throw "Gradle release build failed with exit code $LASTEXITCODE."
}
$outputMetadataPath = Join-Path $repoRoot 'qsferaApp\build\outputs\apk\original\release\output-metadata.json'
if (-not (Test-Path $outputMetadataPath)) {
throw "Release output metadata was not found: $outputMetadataPath"
}
$outputMetadata = Get-Content $outputMetadataPath -Raw | ConvertFrom-Json
$outputFile = $outputMetadata.elements[0].outputFile
$releaseApkPath = Join-Path $repoRoot ("qsferaApp\build\outputs\apk\original\release\{0}" -f $outputFile)
if (-not (Test-Path $releaseApkPath)) {
throw "Release APK was not found: $releaseApkPath"
}
if ($releaseApkPath -like '*-unsigned.apk') {
throw "Release APK is unsigned: $releaseApkPath"
}
$badging = & $aaptPath dump badging $releaseApkPath
if ($LASTEXITCODE -ne 0) {
throw "aapt badging failed with exit code $LASTEXITCODE."
}
$badgingText = ($badging | Out-String)
$packageName = Get-ApkBadgingValue $badgingText "package: name='([^']+)'"
$versionCode = Get-ApkBadgingValue $badgingText "versionCode='([^']+)'"
$versionName = Get-ApkBadgingValue $badgingText "versionName='([^']+)'"
$targetSdk = Get-ApkBadgingValue $badgingText "targetSdkVersion:'([^']+)'"
$applicationLabel = Get-ApkBadgingValue $badgingText "application-label:'([^']+)'"
$nativeCodeLine = ($badging | Where-Object { $_ -like 'native-code:*' } | Select-Object -First 1)
$nativeCodes = if ($nativeCodeLine) {
[regex]::Matches($nativeCodeLine, "'([^']+)'") | ForEach-Object { $_.Groups[1].Value }
} else {
@()
}
$detectedPlatform = Resolve-ArgusPlatform $nativeCodes
$resolvedPlatform = if ([string]::IsNullOrWhiteSpace($Platform)) {
$detectedPlatform
} else {
$Platform.Trim()
}
if (-not $packageName) {
throw 'Package name could not be extracted from APK.'
}
if ($packageName -ne 'eu.qsfera.android') {
throw "Unexpected package name: $packageName"
}
if (-not $versionCode) {
throw 'Version code could not be extracted from APK.'
}
if (-not $versionName) {
throw 'Version name could not be extracted from APK.'
}
if (-not $targetSdk) {
throw 'Target SDK could not be extracted from APK.'
}
$certOutput = & $apksignerPath verify --print-certs $releaseApkPath
if ($LASTEXITCODE -ne 0) {
throw "apksigner verification failed with exit code $LASTEXITCODE."
}
$certText = ($certOutput | Out-String)
$certificateSha256 = Get-ApkBadgingValue $certText "SHA-256 digest: ([0-9a-fA-F]+)"
if ($certText -match 'CN=Android Debug' -or $certificateSha256 -eq 'b86d745cecb01ca24bda03092fe94ca7801678513b97edd1175bcabfdea4e941') {
throw 'Release APK is signed with the Android debug certificate.'
}
$resolvedName = if ($Name) { $Name } elseif ($applicationLabel) { $applicationLabel } else { 'QSfera' }
$releaseVersion = if ($Version) { $Version } else { $versionName }
$stageDirectory = Join-Path $repoRoot ("build\argus-publication\v{0}" -f $versionCode)
if (-not (Test-Path $stageDirectory)) {
New-Item -ItemType Directory -Path $stageDirectory | Out-Null
}
$stagedApkName = "{0}-{1}.apk" -f $Slug, $versionCode
$stagedApkPath = Join-Path $stageDirectory $stagedApkName
Copy-Item $releaseApkPath $stagedApkPath -Force
$metadata = [ordered]@{
packagePath = $stagedApkPath
slug = $Slug
name = $resolvedName
summary = $Summary
description = $Description
version = $releaseVersion
channel = $Channel
platform = $resolvedPlatform
packageKind = 'apk'
notes = if ($Notes) { $Notes } else { $null }
repositoryUrl = if ($RepositoryUrl) { $RepositoryUrl } else { $null }
homepageUrl = if ($HomepageUrl) { $HomepageUrl } else { $null }
androidPackageName = $packageName
androidVersionCode = [int]$versionCode
targetSdkVersion = [int]$targetSdk
isListed = (-not $Unlisted)
verifiedFromApk = [ordered]@{
outputFile = $releaseApkPath
packageName = $packageName
versionCode = [int]$versionCode
versionName = $versionName
targetSdkVersion = [int]$targetSdk
nativeCodes = $nativeCodes
detectedPlatform = $detectedPlatform
applicationLabel = if ($applicationLabel) { $applicationLabel } else { $null }
certificateSha256 = $certificateSha256
verifiedAt = [DateTimeOffset]::UtcNow.ToString('O')
}
}
$metadataPath = Join-Path $stageDirectory 'argus-publication.json'
$metadata | ConvertTo-Json -Depth 6 | Set-Content -Path $metadataPath -Encoding UTF8
Write-Output 'Prepared QSfera Argus publication files.'
Write-Output "APK: $stagedApkPath"
Write-Output "Metadata: $metadataPath"
+141
View File
@@ -0,0 +1,141 @@
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
)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Net.Http
function Read-HttpBody([System.Net.Http.HttpResponseMessage]$Response) {
if ($null -eq $Response.Content) {
return ''
}
return $Response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
}
function Ensure-Success(
[System.Net.Http.HttpResponseMessage]$Response,
[string]$Operation
) {
if ($Response.IsSuccessStatusCode) {
return
}
$body = Read-HttpBody $Response
throw "$Operation failed with status $([int]$Response.StatusCode) ($($Response.ReasonPhrase)). Body: $body"
}
function New-JsonContent([string]$Json) {
return [System.Net.Http.StringContent]::new($Json, [System.Text.Encoding]::UTF8, 'application/json')
}
$resolvedPackagePath = (Resolve-Path -LiteralPath $PackagePath).Path
if (-not (Test-Path $resolvedPackagePath)) {
throw "Package file was not found: $resolvedPackagePath"
}
$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)
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()
}
} finally {
$client.Dispose()
$handler.Dispose()
}
+16
View File
@@ -57,6 +57,8 @@ jobs:
CRAFT_TARGET: ${{ matrix.target }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CRAFT_PACKAGE_SYMBOLS: ${{ github.event_name != 'pull_request' }}
SIGN_PACKAGE: ${{ secrets.QSFERA_DESKTOP_SIGN_PACKAGE }}
CRAFT_CODESIGN_CERTIFICATE: ${{ secrets.QSFERA_DESKTOP_CODESIGN_CERTIFICATE }}
container: ${{ matrix.container }}
@@ -188,11 +190,25 @@ jobs:
- name: Package
if: matrix.target != 'macos-clang-arm64'
run: |
$requireSigning = "${{ matrix.os }}" -eq "windows-latest" -and "${{ github.ref_type }}" -eq "tag" -and "${{ inputs.releaseChannel }}" -ne "beta"
if ("${{ matrix.os }}" -eq "windows-latest") {
$validationArgs = @()
if ($requireSigning) {
$validationArgs += "-RequireSigning"
}
& "${env:GITHUB_WORKSPACE}/scripts/validate-production-release.ps1" @validationArgs
}
& "${env:GITHUB_WORKSPACE}/.github/workflows/.craft.ps1" -c --no-cache --package opencloud/opencloud-desktop
- name: Package Appx
if: ${{ matrix.os == 'windows-latest' && github.event_name != 'pull_request' }}
run: |
$requireSigning = "${{ github.ref_type }}" -eq "tag" -and "${{ inputs.releaseChannel }}" -ne "beta"
$validationArgs = @()
if ($requireSigning) {
$validationArgs += "-RequireSigning"
}
& "${env:GITHUB_WORKSPACE}/scripts/validate-production-release.ps1" @validationArgs
& "${env:GITHUB_WORKSPACE}/.github/workflows/.craft.ps1" -c --no-cache --package --options "[Packager]PackageType=AppxPackager" --options "[Packager]Destination=${{ github.workspace }}/appx/" opencloud/opencloud-desktop
- name: Prepare artifacts
@@ -0,0 +1,60 @@
param(
[switch]$RequireSigning,
[string]$Root = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
)
$ErrorActionPreference = "Stop"
function Fail($Message) {
Write-Error $Message
exit 1
}
function Read-Text($RelativePath) {
Get-Content -LiteralPath (Join-Path $Root $RelativePath) -Raw -Encoding UTF8
}
$branding = Read-Text "QSFERA.cmake"
$cmake = Read-Text "CMakeLists.txt"
$craft = Read-Text ".craft.ini"
$expectedAppName = -join ([char[]](0x041a, 0x0443, 0x0421, 0x0444, 0x0435, 0x0440, 0x0430))
if (-not $branding.Contains("APPLICATION_NAME") -or -not $branding.Contains($expectedAppName)) {
Fail "Desktop branding must use the production Cyrillic application name."
}
if ($branding -notmatch 'APPLICATION_EXECUTABLE\s+"qsfera"') {
Fail "Desktop executable must remain `"qsfera`" for the production profile."
}
if ($branding -notmatch 'APPLICATION_REV_DOMAIN\s+"eu\.qsfera\.desktop"') {
Fail "Desktop reverse-domain id must remain eu.qsfera.desktop for the production profile."
}
if ($branding -notmatch 'APPLICATION_DEFAULT_SERVER_URL\s+"https://qsfera\.kusoft\.xyz"') {
Fail "Desktop default server URL must be https://qsfera.kusoft.xyz."
}
if ($cmake -notmatch 'option\(WITH_UPDATE_NOTIFICATION\s+"Whether to check for new releases"\s+ON\)') {
Fail "Desktop release builds must keep update notifications enabled by default."
}
if ($cmake -notmatch 'VIRTUAL_FILE_SYSTEM_PLUGINS\s+off\s+cfapi\s+openvfs') {
Fail "Desktop release builds must include the Windows CfAPI VFS plugin."
}
if ($craft -notmatch 'CodeSigning/Enabled\s*=\s*\$\{Env:SIGN_PACKAGE\}') {
Fail "Craft code-signing must be controlled by SIGN_PACKAGE."
}
if ($RequireSigning) {
if ($env:SIGN_PACKAGE -notin @("True", "true", "1", "yes", "YES")) {
Fail "Production Windows tag releases require SIGN_PACKAGE=True."
}
if ([string]::IsNullOrWhiteSpace($env:CRAFT_CODESIGN_CERTIFICATE)) {
Fail "Production Windows tag releases require CRAFT_CODESIGN_CERTIFICATE."
}
}
Write-Host "QSfera Desktop production release validation passed."
+1 -1
View File
@@ -331,7 +331,7 @@ void ConfigFile::setSkipUpdateCheck(bool skip)
QString ConfigFile::updateChannel() const
{
auto settings = makeQSettings();
return settings.value(updateChannelC(), OCC::Version::isBeta() ? u"stable"_s : u"beta"_s).toString();
return settings.value(updateChannelC(), OCC::Version::isBeta() ? u"beta"_s : u"stable"_s).toString();
}
void ConfigFile::setUpdateChannel(const QString &channel)
+1
View File
@@ -13,6 +13,7 @@ qsfera_add_test(ConcatUrl)
qsfera_add_test(XmlParse)
qsfera_add_test(ChecksumValidator)
qsfera_add_test(ConnectionValidator)
qsfera_add_test(ConfigFile)
# TODO: we need keychain access for this test
+52
View File
@@ -0,0 +1,52 @@
/*
* Copyright (C) QSfera
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*/
#include "configfile.h"
#include "common/version.h"
#include <QFile>
#include <QTemporaryDir>
#include <QtTest>
using namespace OCC;
using namespace Qt::Literals::StringLiterals;
class TestConfigFile : public QObject
{
Q_OBJECT
private Q_SLOTS:
void init()
{
QVERIFY(_configDir.isValid());
QVERIFY(ConfigFile::setConfDir(_configDir.path()));
QFile::remove(ConfigFile::configFile());
}
void testDefaultUpdateChannelMatchesBuildChannel()
{
const auto expectedChannel = Version::isBeta() ? u"beta"_s : u"stable"_s;
QCOMPARE(ConfigFile().updateChannel(), expectedChannel);
}
void testStoredUpdateChannelOverridesBuildDefault()
{
ConfigFile().setUpdateChannel(u"beta"_s);
QCOMPARE(ConfigFile().updateChannel(), u"beta"_s);
ConfigFile().setUpdateChannel(u"stable"_s);
QCOMPARE(ConfigFile().updateChannel(), u"stable"_s);
}
private:
QTemporaryDir _configDir;
};
QTEST_MAIN(TestConfigFile)
#include "testconfigfile.moc"
+72
View File
@@ -0,0 +1,72 @@
# QSfera Production Readiness
Date: 2026-06-08.
## Benchmarks Used
- Google Photos Android backup: automatic photo/video backup, selected device folders, backup status, mobile-data limits, roaming controls, and battery optimization guidance.
Source: https://support.google.com/photos/answer/6193313?co=GENIE.Platform%3DAndroid&hl=en
- Google Drive Android offline files and sharing roles.
Sources: https://support.google.com/drive/answer/2375012?co=GENIE.Platform%3DAndroid&hl=en and https://support.google.com/drive/answer/2494822?co=GENIE.Platform%3DAndroid&hl=en
- Google storage quota behavior across Drive and Photos.
Source: https://support.google.com/drive/answer/9312312?hl=en
- Google Drive for desktop: tray entry, multi-account sign-in, sync status, notifications, offline files, pause/resume sync, adding folders, and stream/mirror model.
Sources: https://support.google.com/drive/answer/10838124?co=GENIE.Platform%3DDesktop&hl=en-fm and https://support.google.com/drive/answer/13401938?hl=en
- Yandex Disk Android auto-upload setup, account choice, network choice, source folders, and uploaded/not-uploaded indicators.
Source: https://yandex.com/support/yandex-360/customers/disk/app/android/en/autoupload/setup
- Yandex Disk mobile link sharing controls: view-only, disable downloads, password, expiration, and organization-only links.
Source: https://yandex.com/support/yandex-360/customers/disk/app/android/en/actions/share
- Yandex Disk desktop: Windows/macOS desktop app, selective sync, auto-save folders, automatic update, autorun, and sleep-prevention behavior during sync.
Sources: https://www.yandex.com/support/yandex-360/customers/disk/web/en/desktop, https://www.yandex.com/support/yandex-360/customers/disk/desktop/windows/en/reserve-copy, and https://yandex.com/support/yandex-360/customers/disk/desktop/windows/en/installation
## Completed In This Pass
- 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.6` with `versionCode` `34`.
- 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 `1.3.6`/`34` was published to Argus as `qsfera-mobile` on 2026-06-08. Public manifest and latest download both report SHA-256 `921f2dad21aa85991a30ab5e7792f79c32f87ba5bb14188185ff2e6a438b4428`.
- 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.
- Android new auto-upload configurations default to Wi-Fi-only transfers for photos and videos.
- 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.
- 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`.
- Windows/Desktop update channel defaults now match the build channel: stable builds default to `stable`, beta builds default to `beta`.
- Windows/Desktop CI now validates production release invariants before Windows packaging and requires signing variables for stable tag releases.
## Raspberry Pi 5 Check
- Pi target checked on 2026-06-08: `192.168.0.185` reports `aarch64`, 64-bit Debian 13, Docker `29.4.3`, Docker Compose `v5.1.3`, and Docker server architecture `aarch64`.
- Current QSfera server is already running on that Pi as `qsfera-cloud-server:rpi5-local`, with `Arch=arm64`, bind-mounted config/data under `/mnt/data/qsfera/cloud-server`, and loopback port `127.0.0.1:9200`.
- `https://qsfera.kusoft.xyz/status.php` returned HTTP `200` during the check.
- The Pi root filesystem is nearly full: `/dev/mmcblk0p2` reported `57G` size, `52G` used, `2.4G` available, `96%` use. `/mnt/data` reported `916G` size, `18G` used, `853G` available, `2%` use.
- This is deployable on the Pi, but I do not count it as fully production-ready yet because the running image is a local/dev build (`qsfera-cloud-server:rpi5-local`, external status reports `edition: dev`) rather than a pinned production release image.
## 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 `bf2c78ad-7a26-459c-93f9-42b54c039279`, version `1.3.6`, channel `stable`, platform `android`, package kind `apk`, `androidVersionCode` `34`, package size `12217032`, and SHA-256 `921f2dad21aa85991a30ab5e7792f79c32f87ba5bb14188185ff2e6a438b4428`.
- Independent download verification of `https://argus.kusoft.xyz/api/apps/qsfera-mobile/download/latest?platform=android&channel=stable` produced a `12217032` byte APK with the same SHA-256 `921f2dad21aa85991a30ab5e7792f79c32f87ba5bb14188185ff2e6a438b4428`.
- 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.6`/`34` 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 production-signed `1.3.6`.
## 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 `1.3.6` 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`.
- Decide the rollout policy for any devices that installed the debug-signed Argus `1.3.5` APK. The production-signed `1.3.6` APK is the correct current release, but installed debug-signed clients will not satisfy the app's own signer-digest check for an in-place update.
- Fill production domains, ACME email, admin passwords, Keycloak passwords, and storage secrets from a secret manager. Do not commit real values.
- Run `./validate-production-env.sh .env` and then `docker compose config` for `Server/devtools/deployments/qsfera_full` with a filled production `.env`; Docker CLI is not available in this local environment, so compose rendering is not locally verified.
- For the Pi deployment, switch the running service from the direct Docker container to `Server/docker-compose.rpi5.yml` only after backing up `/mnt/data/qsfera/cloud-server/config` and `/mnt/data/qsfera/cloud-server/data`.
- Provide real Windows signing secrets in CI: `QSFERA_DESKTOP_SIGN_PACKAGE=True` and `QSFERA_DESKTOP_CODESIGN_CERTIFICATE` with the Craft-compatible signing certificate reference.
- Configure and verify the desktop updater endpoint via `APPLICATION_UPDATE_URL`; `Desktop/scripts/validate-production-release.ps1` confirms update notifications are enabled but cannot prove a live update feed without that URL.
## Remaining P1
- Add Google Photos-style backup status UX: overall status, per-item status, queued/paused/permanent-failure states, and low-storage messaging.
- Add mobile-data controls comparable to Google Photos/Yandex Disk beyond the Wi-Fi-only default: mobile-data limit, no-video-over-mobile-data, and no-roaming options.
- Store auto-upload sync cursors per source folder rather than one timestamp for all folders.
- Add production monitoring runbook and compose verification for metrics, tracing, alerts, backups, and restore drills.
- Verify sharing controls against Google Drive and Yandex Disk expectations: link expiration, password, disable downloads, organization-only access, and role inheritance.
+10
View File
@@ -0,0 +1,10 @@
QSFERA_RPI_IMAGE=qsfera-cloud-server:rpi5-local
QSFERA_RPI_HOST_PORT=9200
QSFERA_RPI_CONFIG_PATH=/mnt/data/qsfera/cloud-server/config
QSFERA_RPI_DATA_PATH=/mnt/data/qsfera/cloud-server/data
OC_URL=https://qsfera.example.com
OC_LOG_LEVEL=info
OC_INSECURE=false
PROXY_TLS=true
FRONTEND_CHECK_FOR_UPDATES=false
+2 -1
View File
@@ -61,8 +61,9 @@ protogen/buf.sha1.lock
go.work
go.work.sum
.env
.env.server
.envrc
.DS_Store
# example deployments
**/qsfera-sandbox-*
**/qsfera-sandbox-*
@@ -15,4 +15,6 @@ This deployment example is documented in two locations for different audiences:
Providing details which are more developer focused. This description can also be used when deviating from the default.\
Note that this examples uses self signed certificates and is intended for testing purposes.
For production rollouts from this compose directory, use `env.production.example` as the starting point for `.env`.
It keeps TLS validation enabled, disables demo users, and requires explicit admin, Keycloak, and S3 secrets instead of demo defaults.
Run `./validate-production-env.sh .env` before `docker compose config` and rollout.
@@ -9,6 +9,6 @@ services:
# decomposeds3 specific settings
STORAGE_USERS_DECOMPOSEDS3_ENDPOINT: ${DECOMPOSEDS3_ENDPOINT:-http://minio:9000}
STORAGE_USERS_DECOMPOSEDS3_REGION: ${DECOMPOSEDS3_REGION:-default}
STORAGE_USERS_DECOMPOSEDS3_ACCESS_KEY: ${DECOMPOSEDS3_ACCESS_KEY:-qsfera}
STORAGE_USERS_DECOMPOSEDS3_SECRET_KEY: ${DECOMPOSEDS3_SECRET_KEY:-qsfera-secret-key}
STORAGE_USERS_DECOMPOSEDS3_BUCKET: ${DECOMPOSEDS3_BUCKET:-qsfera-bucket}
STORAGE_USERS_DECOMPOSEDS3_ACCESS_KEY: ${DECOMPOSEDS3_ACCESS_KEY:?Set DECOMPOSEDS3_ACCESS_KEY before starting decomposeds3 in production}
STORAGE_USERS_DECOMPOSEDS3_SECRET_KEY: ${DECOMPOSEDS3_SECRET_KEY:?Set DECOMPOSEDS3_SECRET_KEY before starting decomposeds3 in production}
STORAGE_USERS_DECOMPOSEDS3_BUCKET: ${DECOMPOSEDS3_BUCKET:?Set DECOMPOSEDS3_BUCKET before starting decomposeds3 in production}
@@ -0,0 +1,49 @@
## Production-safe qsfera_full environment template.
## Copy to .env and fill every placeholder before running docker compose.
COMPOSE_PATH_SEPARATOR=:
QSFERA=:qsfera.yml
## Public deployment identity.
OC_DOMAIN=<cloud.example.com>
TRAEFIK_ACME_MAIL=<admin@example.com>
## Use the production image and pin a release tag for repeatable rollouts.
OC_DOCKER_IMAGE=qsfera/qsfera
OC_DOCKER_TAG=<release-tag>
## Production must validate TLS certificates and must not create demo users.
INSECURE=false
DEMO_USERS=false
## Required by qsfera.yml.
ADMIN_PASSWORD=<replace-with-strong-password>
## Optional: enable Keycloak-backed production identity.
## Uncomment KEYCLOAK and fill all values before enabling it.
#KEYCLOAK=:keycloak.yml
KEYCLOAK_DOMAIN=<keycloak.example.com>
KEYCLOAK_REALM=qsfera
KEYCLOAK_POSTGRES_PASSWORD=<replace-with-strong-password>
KEYCLOAK_ADMIN_USER=<replace-with-admin-user>
KEYCLOAK_ADMIN_PASSWORD=<replace-with-strong-password>
## Optional: enable S3-backed user storage.
## Uncomment DECOMPOSEDS3 and fill all values before enabling it.
#DECOMPOSEDS3=:decomposeds3.yml
DECOMPOSEDS3_ENDPOINT=<https://s3.example.com>
DECOMPOSEDS3_REGION=<region>
DECOMPOSEDS3_ACCESS_KEY=<replace-with-access-key>
DECOMPOSEDS3_SECRET_KEY=<replace-with-secret-key>
DECOMPOSEDS3_BUCKET=<bucket>
## Optional: local MinIO is intended for test/lab installs, not public production.
#DECOMPOSEDS3_MINIO=:minio.yml
#MINIO_DOMAIN=<minio.example.com>
## Optional: use host paths for backup-friendly config and data storage.
#OC_CONFIG_DIR=/srv/qsfera/config
#OC_DATA_DIR=/srv/qsfera/data
## Keep this last: it assembles the selected compose files.
COMPOSE_FILE=docker-compose.yml${QSFERA:-}${DECOMPOSEDS3:-}${DECOMPOSEDS3_MINIO:-}${KEYCLOAK:-}
@@ -33,7 +33,7 @@ services:
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak
POSTGRES_PASSWORD: ${KEYCLOAK_POSTGRES_PASSWORD:?Set KEYCLOAK_POSTGRES_PASSWORD before starting Keycloak in production}
logging:
driver: ${LOG_DRIVER:-local}
restart: always
@@ -53,10 +53,10 @@ services:
KC_DB: postgres
KC_DB_URL: "jdbc:postgresql://postgres:5432/keycloak"
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak
KC_DB_PASSWORD: ${KEYCLOAK_POSTGRES_PASSWORD:?Set KEYCLOAK_POSTGRES_PASSWORD before starting Keycloak in production}
KC_FEATURES: impersonation
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:-admin}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-admin}
KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN_USER:?Set KEYCLOAK_ADMIN_USER before starting Keycloak in production}
KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?Set KEYCLOAK_ADMIN_PASSWORD before starting Keycloak in production}
labels:
- "traefik.enable=true"
- "traefik.http.routers.keycloak.entrypoints=https"
@@ -10,13 +10,13 @@ services:
command:
[
"-c",
"mkdir -p /data/${DECOMPOSEDS3_BUCKET:-qsfera-bucket} && minio server --console-address ':9001' /data",
"mkdir -p /data/${DECOMPOSEDS3_BUCKET:?Set DECOMPOSEDS3_BUCKET before starting MinIO in production} && minio server --console-address ':9001' /data",
]
volumes:
- minio-data:/data
environment:
MINIO_ROOT_USER: ${DECOMPOSEDS3_ACCESS_KEY:-qsfera}
MINIO_ROOT_PASSWORD: ${DECOMPOSEDS3_SECRET_KEY:-qsfera-secret-key}
MINIO_ROOT_USER: ${DECOMPOSEDS3_ACCESS_KEY:?Set DECOMPOSEDS3_ACCESS_KEY before starting MinIO in production}
MINIO_ROOT_PASSWORD: ${DECOMPOSEDS3_SECRET_KEY:?Set DECOMPOSEDS3_SECRET_KEY before starting MinIO in production}
labels:
- "traefik.enable=true"
- "traefik.http.routers.minio.entrypoints=https"
@@ -33,7 +33,7 @@ services:
# basic auth (not recommended, but needed for eg. WebDav clients that do not support OpenID Connect)
PROXY_ENABLE_BASIC_AUTH: "${PROXY_ENABLE_BASIC_AUTH:-false}"
# admin user password
IDM_ADMIN_PASSWORD: "${ADMIN_PASSWORD:-admin}" # this overrides the admin password from the configuration file
IDM_ADMIN_PASSWORD: "${ADMIN_PASSWORD:?Set ADMIN_PASSWORD before starting qsfera_full in production}" # this overrides the admin password from the configuration file
# demo users
IDM_CREATE_DEMO_USERS: "${DEMO_USERS:-false}"
# idp login form settings
@@ -0,0 +1,155 @@
#!/usr/bin/env sh
set -eu
ENV_FILE="${1:-.env}"
errors=0
warnings=0
if [ ! -f "$ENV_FILE" ]; then
echo "ERROR: env file not found: $ENV_FILE" >&2
exit 1
fi
value() {
key="$1"
awk -v key="$key" '
/^[[:space:]]*#/ || /^[[:space:]]*$/ { next }
{
line = $0
sub(/\r$/, "", line)
split(line, parts, "=")
current = parts[1]
gsub(/^[ \t]+|[ \t]+$/, "", current)
if (current == key) {
sub(/^[^=]*=/, "", line)
gsub(/^"/, "", line)
gsub(/"$/, "", line)
gsub(/^'\''/, "", line)
gsub(/'\''$/, "", line)
print line
exit
}
}
' "$ENV_FILE"
}
is_placeholder() {
case "${1:-}" in
""|"<"*">"|*example.com*|"change-me"|"changeme"|"password"|"secret")
return 0
;;
*)
return 1
;;
esac
}
error() {
errors=$((errors + 1))
echo "ERROR: $1" >&2
}
warn() {
warnings=$((warnings + 1))
echo "WARN: $1" >&2
}
require_value() {
key="$1"
label="${2:-$1}"
current="$(value "$key")"
if is_placeholder "$current"; then
error "$label must be set to a production value."
fi
}
require_secret() {
key="$1"
current="$(value "$key")"
if is_placeholder "$current"; then
error "$key must be set."
return
fi
case "$current" in
admin|demo|keycloak|qsfera|qsfera-secret-key)
error "$key uses a known demo/default value."
return
;;
esac
if [ "${#current}" -lt 16 ]; then
error "$key must be at least 16 characters."
fi
}
enabled() {
current="$(value "$1")"
[ -n "$current" ]
}
require_value OC_DOMAIN "OC_DOMAIN"
require_value TRAEFIK_ACME_MAIL "TRAEFIK_ACME_MAIL"
require_secret ADMIN_PASSWORD
case "$(value INSECURE)" in
true|TRUE|1|yes|YES)
error "INSECURE must be false for production."
;;
esac
case "$(value DEMO_USERS)" in
true|TRUE|1|yes|YES)
error "DEMO_USERS must be false for production."
;;
esac
case "$(value OC_DOCKER_IMAGE)" in
qsfera/qsfera)
;;
"")
warn "OC_DOCKER_IMAGE is empty; compose default must be verified before rollout."
;;
*)
error "OC_DOCKER_IMAGE must be qsfera/qsfera for production."
;;
esac
case "$(value OC_DOCKER_TAG)" in
""|latest|"<"*">")
error "OC_DOCKER_TAG must pin a concrete production release tag."
;;
esac
if ! enabled QSFERA; then
error "QSFERA=:qsfera.yml must be enabled."
fi
if enabled DECOMPOSEDS3; then
require_value DECOMPOSEDS3_ENDPOINT "DECOMPOSEDS3_ENDPOINT"
require_value DECOMPOSEDS3_REGION "DECOMPOSEDS3_REGION"
require_secret DECOMPOSEDS3_ACCESS_KEY
require_secret DECOMPOSEDS3_SECRET_KEY
require_value DECOMPOSEDS3_BUCKET "DECOMPOSEDS3_BUCKET"
fi
if enabled DECOMPOSEDS3_MINIO; then
error "DECOMPOSEDS3_MINIO is for test/lab installs and must not be enabled for public production."
fi
if enabled KEYCLOAK; then
require_value KEYCLOAK_DOMAIN "KEYCLOAK_DOMAIN"
require_value KEYCLOAK_REALM "KEYCLOAK_REALM"
require_secret KEYCLOAK_POSTGRES_PASSWORD
require_value KEYCLOAK_ADMIN_USER "KEYCLOAK_ADMIN_USER"
require_secret KEYCLOAK_ADMIN_PASSWORD
fi
if [ -z "$(value OC_CONFIG_DIR)" ] || [ -z "$(value OC_DATA_DIR)" ]; then
warn "OC_CONFIG_DIR and OC_DATA_DIR are not both set; docker volumes are harder to back up and restore."
fi
if [ "$errors" -gt 0 ]; then
echo "Production env validation failed: $errors error(s), $warnings warning(s)." >&2
exit 1
fi
echo "Production env validation passed: $warnings warning(s)."
+35
View File
@@ -0,0 +1,35 @@
services:
qsfera-cloud-server:
container_name: qsfera-cloud-server
image: ${QSFERA_RPI_IMAGE:-qsfera-cloud-server:rpi5-local}
platform: linux/arm64
build:
context: .
dockerfile: Dockerfile
args:
TARGETARCH: arm64
restart: unless-stopped
entrypoint:
- /bin/sh
command:
- -c
- qsfera init || true; exec qsfera server
environment:
OC_LOG_LEVEL: ${OC_LOG_LEVEL:-info}
FRONTEND_CHECK_FOR_UPDATES: ${FRONTEND_CHECK_FOR_UPDATES:-false}
OC_CONFIG_DIR: /etc/qsfera
OC_BASE_DATA_PATH: /var/lib/qsfera
OC_URL: ${OC_URL:?OC_URL must be set in .env}
OC_INSECURE: ${OC_INSECURE:-false}
PROXY_TLS: ${PROXY_TLS:-true}
ports:
- "127.0.0.1:${QSFERA_RPI_HOST_PORT:-9200}:9200"
volumes:
- ${QSFERA_RPI_CONFIG_PATH:?QSFERA_RPI_CONFIG_PATH must be set in .env}:/etc/qsfera
- ${QSFERA_RPI_DATA_PATH:?QSFERA_RPI_DATA_PATH must be set in .env}:/var/lib/qsfera
healthcheck:
test: ["CMD-SHELL", "curl -kfsS https://127.0.0.1:9200/status.php >/dev/null || exit 1"]
interval: 30s
timeout: 5s
retries: 5
start_period: 60s
+107
View File
@@ -0,0 +1,107 @@
# Raspberry Pi 5 Docker Run
This server repository contains a Dockerized QSfera path for Raspberry Pi 5 and other
64-bit ARM Linux hosts.
Files used for this deployment:
- `Dockerfile`
- `docker-compose.rpi5.yml`
- `.env.example`
## What This Runs
- QSfera server from this `Server` source tree.
- QSfera config persisted under `/etc/qsfera` inside the container.
- QSfera data persisted under `/var/lib/qsfera` inside the container.
- Host-side config and data paths controlled by `QSFERA_RPI_CONFIG_PATH` and
`QSFERA_RPI_DATA_PATH`.
- Host-side backend port bound only to `127.0.0.1:9200`; external access should go
through the reverse proxy and HTTPS domain.
## Prerequisites On Raspberry Pi 5
1. Install Docker Engine using the official Docker docs: `https://docs.docker.com/engine/install/debian/`.
2. Install the Docker Compose plugin using the official Docker docs: `https://docs.docker.com/compose/install/`.
3. Use a 64-bit OS. The compose file sets `platform: linux/arm64`, and the Dockerfile builds with `TARGETARCH=arm64`.
4. Put `QSFERA_RPI_CONFIG_PATH` and `QSFERA_RPI_DATA_PATH` on durable storage, not on a nearly full SD-card root filesystem.
5. Set `OC_URL` to the public HTTPS URL used by clients and the reverse proxy.
## Start
Run from the `Server` directory:
```bash
cp .env.example .env
docker compose -f docker-compose.rpi5.yml up -d --build
```
## Stop
```bash
docker compose -f docker-compose.rpi5.yml down
```
## Logs
```bash
docker compose -f docker-compose.rpi5.yml logs -f qsfera-cloud-server
```
## Update After Pulling New Code
```bash
docker compose -f docker-compose.rpi5.yml up -d --build
```
## Verify
Local backend status endpoint:
```bash
curl -k https://127.0.0.1:9200/status.php
```
External reverse-proxied status endpoint:
```bash
curl https://qsfera.example.com/status.php
```
## Data Location
Docker bind mounts:
- host config: `${QSFERA_RPI_CONFIG_PATH}`
- container config: `/etc/qsfera`
- host data: `${QSFERA_RPI_DATA_PATH}`
- container data: `/var/lib/qsfera`
For the current Raspberry Pi deployment inspected on 2026-06-08, the existing
container uses:
- host config: `/mnt/data/qsfera/cloud-server/config`
- host data: `/mnt/data/qsfera/cloud-server/data`
- public URL: `https://qsfera.kusoft.xyz`
- loopback port: `127.0.0.1:9200`
## Reverse Proxy
The server is intentionally bound to loopback. Put Caddy, Nginx, Traefik, or another
reverse proxy in front of it for external HTTPS access.
The inspected Raspberry Pi uses Caddy with this QSfera route:
```caddyfile
qsfera.kusoft.xyz {
encode zstd gzip
handle {
reverse_proxy https://127.0.0.1:9200 {
transport http {
tls_insecure_skip_verify
versions 1.1
}
}
}
}
```