Prepare QSfera production release
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
+4
-3
@@ -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 {
|
||||
|
||||
+4
-3
@@ -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 {
|
||||
|
||||
+24
-19
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
@@ -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"
|
||||
@@ -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()
|
||||
}
|
||||
Vendored
+16
@@ -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."
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -61,6 +61,7 @@ protogen/buf.sha1.lock
|
||||
go.work
|
||||
go.work.sum
|
||||
.env
|
||||
.env.server
|
||||
.envrc
|
||||
.DS_Store
|
||||
|
||||
|
||||
@@ -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)."
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user