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