Align Keeper Argus publication flow

This commit is contained in:
Курнат Андрей
2026-06-20 19:53:04 +03:00
parent cdd442ebf7
commit 39d65fcede
5 changed files with 305 additions and 105 deletions
+4 -10
View File
@@ -37,17 +37,11 @@ go run .\services\keepernotes\cmd\keepernotes -addr :8098 -data .\keeper-notes.j
## Publish to Argus ## 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` Build the APK, then run:
* `PUT /api/admin/apps/{slug}`
* `POST /api/admin/apps/{slug}/releases`
Set credentials in the current shell, then run:
```powershell ```powershell
$env:ARGUS_USERNAME='admin'
$env:ARGUS_PASSWORD='<password>'
.\scripts\publish-argus.ps1 .\scripts\publish-argus.ps1
``` ```
@@ -57,6 +51,6 @@ Default publish metadata:
* package: `eu.qsfera.keeper` * package: `eu.qsfera.keeper`
* name: `QKeep` * name: `QKeep`
* Android label: `КуЗаметки` * Android label: `КуЗаметки`
* version: `0.1.21` * version: `0.1.22`
* Android version code: `22` * Android version code: `23`
* channel: `stable` * channel: `stable`
+2 -2
View File
@@ -23,8 +23,8 @@ android {
applicationId "eu.qsfera.keeper" applicationId "eu.qsfera.keeper"
minSdkVersion sdkMinVersion minSdkVersion sdkMinVersion
targetSdkVersion sdkTargetVersion targetSdkVersion sdkTargetVersion
versionCode 22 versionCode 23
versionName "0.1.21" versionName "0.1.22"
def apiBaseUrl = project.findProperty("keeperApiBaseUrl") ?: "https://qsfera.kusoft.xyz/keeper-notes" def apiBaseUrl = project.findProperty("keeperApiBaseUrl") ?: "https://qsfera.kusoft.xyz/keeper-notes"
def cloudBaseUrl = project.findProperty("qsferaCloudBaseUrl") ?: "https://qsfera.kusoft.xyz" def cloudBaseUrl = project.findProperty("qsferaCloudBaseUrl") ?: "https://qsfera.kusoft.xyz"
@@ -161,6 +161,7 @@ enum class BodyTextStyle {
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var authPreferences: AuthPreferences private lateinit var authPreferences: AuthPreferences
private lateinit var keepSettings: KeepSettings private lateinit var keepSettings: KeepSettings
private lateinit var versionCleanup: VersionCleanup
private lateinit var authClient: QsferaAuthClient private lateinit var authClient: QsferaAuthClient
private lateinit var repository: NotesRepository private lateinit var repository: NotesRepository
private val updateClient = AppUpdateClient(ARGUS_MANIFEST_URL) private val updateClient = AppUpdateClient(ARGUS_MANIFEST_URL)
@@ -183,10 +184,11 @@ class MainActivity : AppCompatActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
authPreferences = AuthPreferences(applicationContext) authPreferences = AuthPreferences(applicationContext)
keepSettings = KeepSettings(applicationContext) keepSettings = KeepSettings(applicationContext)
versionCleanup = VersionCleanup(applicationContext)
authClient = QsferaAuthClient(BuildConfig.QSFERA_CLOUD_BASE_URL) authClient = QsferaAuthClient(BuildConfig.QSFERA_CLOUD_BASE_URL)
repository = NotesRepository(applicationContext, authPreferences, authClient) repository = NotesRepository(applicationContext, authPreferences, authClient)
configureSystemBars() configureSystemBars()
VersionCleanup(applicationContext).cleanupAfterUpdate(BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME) versionCleanup.cleanupAfterUpdate(BuildConfig.VERSION_CODE, BuildConfig.VERSION_NAME)
ReminderScheduler(applicationContext).ensureNotificationChannel() ReminderScheduler(applicationContext).ensureNotificationChannel()
ensureNotificationPermission() ensureNotificationPermission()
@@ -799,12 +801,15 @@ class MainActivity : AppCompatActivity() {
checkButton.isEnabled = true checkButton.isEnabled = true
updateStatus.text = result.fold( updateStatus.text = result.fold(
onSuccess = { latest -> 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 availableUpdate = latest
installButton.visibility = View.VISIBLE installButton.visibility = View.VISIBLE
getString(R.string.update_available, latest.version, latest.androidVersionCode) getString(R.string.update_available, latest.version)
} else { } else {
getString(R.string.up_to_date, BuildConfig.VERSION_NAME, BuildConfig.VERSION_CODE) getString(R.string.up_to_date, installedVersion)
} }
}, },
onFailure = { onFailure = {
@@ -827,6 +832,7 @@ class MainActivity : AppCompatActivity() {
installButton.isEnabled = true installButton.isEnabled = true
result result
.onSuccess { apk -> .onSuccess { apk ->
versionCleanup.markUpdateStaged(update.version)
updateStatus.text = getString(R.string.opening_installer) updateStatus.text = getString(R.string.opening_installer)
installApk(apk) installApk(apk)
} }
@@ -3099,13 +3105,13 @@ data class PendingOAuth(
data class AppUpdateInfo( data class AppUpdateInfo(
val version: String, val version: String,
val androidVersionCode: Int,
val downloadUrl: String, val downloadUrl: String,
val sha256: String?, val sha256: String,
val packageSizeBytes: Long?,
) )
class AppUpdateClient(private val manifestUrl: String) { class AppUpdateClient(private val manifestUrl: String) {
fun latest(): AppUpdateInfo { fun latest(): AppUpdateInfo? {
val connection = (URL(manifestUrl).openConnection() as HttpURLConnection).apply { val connection = (URL(manifestUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "GET" requestMethod = "GET"
connectTimeout = 8000 connectTimeout = 8000
@@ -3116,16 +3122,22 @@ class AppUpdateClient(private val manifestUrl: String) {
return try { return try {
val response = if (connection.responseCode in 200..299) { val response = if (connection.responseCode in 200..299) {
connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() } connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
} else if (connection.responseCode == HttpURLConnection.HTTP_NOT_FOUND) {
return null
} else { } else {
val errorText = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty() val errorText = connection.errorStream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() }.orEmpty()
throw IOException("HTTP ${connection.responseCode}: $errorText") throw IOException("HTTP ${connection.responseCode}: $errorText")
} }
val release = JSONObject(response).getJSONObject("release") 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( AppUpdateInfo(
version = release.getString("version"), version = release.getString("version"),
androidVersionCode = release.getInt("androidVersionCode"),
downloadUrl = release.getString("downloadPath").toAbsoluteArgusUrl(), downloadUrl = release.getString("downloadPath").toAbsoluteArgusUrl(),
sha256 = release.optString("sha256").takeIf { it.isNotBlank() } sha256 = sha256,
packageSizeBytes = release.optLong("packageSizeBytes", -1L).takeIf { it >= 0L }
) )
} finally { } finally {
connection.disconnect() connection.disconnect()
@@ -3139,7 +3151,10 @@ class AppUpdateClient(private val manifestUrl: String) {
updateDir.mkdirs() updateDir.mkdirs()
} }
val apk = File(updateDir, "keeper-android-${update.version}.apk") 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") val digest = MessageDigest.getInstance("SHA-256")
var downloadedBytes = 0L
val connection = (URL(update.downloadUrl).openConnection() as HttpURLConnection).apply { val connection = (URL(update.downloadUrl).openConnection() as HttpURLConnection).apply {
requestMethod = "GET" requestMethod = "GET"
connectTimeout = 8000 connectTimeout = 8000
@@ -3153,25 +3168,38 @@ class AppUpdateClient(private val manifestUrl: String) {
throw IOException("HTTP ${connection.responseCode}: $errorText") throw IOException("HTTP ${connection.responseCode}: $errorText")
} }
connection.inputStream.use { input -> connection.inputStream.use { input ->
apk.outputStream().use { output -> tempApk.outputStream().use { output ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE) val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
while (true) { while (true) {
val read = input.read(buffer) val read = input.read(buffer)
if (read < 0) break if (read < 0) break
digest.update(buffer, 0, read) digest.update(buffer, 0, read)
output.write(buffer, 0, read) output.write(buffer, 0, read)
downloadedBytes += read
} }
} }
} }
} catch (error: Exception) {
tempApk.delete()
throw error
} finally { } finally {
connection.disconnect() connection.disconnect()
} }
val actualSha256 = digest.digest().toHexString() val actualSha256 = digest.digest().toHexString()
val expectedSha256 = update.sha256 if (!update.sha256.equals(actualSha256, ignoreCase = true)) {
if (expectedSha256 != null && !expectedSha256.equals(actualSha256, ignoreCase = true)) { tempApk.delete()
apk.delete()
throw IOException("Downloaded APK checksum mismatch") 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 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) { class VersionCleanup(private val context: Context) {
private val preferences = context.getSharedPreferences("keeper_runtime", Context.MODE_PRIVATE) private val preferences = context.getSharedPreferences("keeper_runtime", Context.MODE_PRIVATE)
@@ -3193,10 +3247,26 @@ class VersionCleanup(private val context: Context) {
cleanupOldPackageFiles(currentVersionName) cleanupOldPackageFiles(currentVersionName)
preferences.edit() preferences.edit()
.putInt(KEY_LAST_VERSION_CODE, currentVersionCode) .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() .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) { fun cleanupOldPackageFiles(currentVersionName: String) {
packageFileRoots().forEach { root -> packageFileRoots().forEach { root ->
if (!root.exists()) return@forEach if (!root.exists()) return@forEach
@@ -3229,6 +3299,9 @@ class VersionCleanup(private val context: Context) {
private companion object { private companion object {
const val KEY_LAST_VERSION_CODE = "last_version_code" 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="checking_updates">Проверяю обновления…</string>
<string name="downloading_update">Скачиваю обновление…</string> <string name="downloading_update">Скачиваю обновление…</string>
<string name="opening_installer">Открываю установщик Android…</string> <string name="opening_installer">Открываю установщик Android…</string>
<string name="up_to_date">Установлена актуальная версия %1$s (%2$d).</string> <string name="up_to_date">&#1059;&#1089;&#1090;&#1072;&#1085;&#1086;&#1074;&#1083;&#1077;&#1085;&#1072; &#1072;&#1082;&#1090;&#1091;&#1072;&#1083;&#1100;&#1085;&#1072;&#1103; &#1074;&#1077;&#1088;&#1089;&#1080;&#1103; %1$s.</string>
<string name="update_available">Доступна версия %1$s (%2$d).</string> <string name="no_update_available">&#1042; Argus &#1085;&#1077;&#1090; &#1088;&#1077;&#1083;&#1080;&#1079;&#1072; &#1076;&#1083;&#1103; &#1101;&#1090;&#1086;&#1075;&#1086; &#1087;&#1088;&#1080;&#1083;&#1086;&#1078;&#1077;&#1085;&#1080;&#1103;.</string>
<string name="update_available">&#1044;&#1086;&#1089;&#1090;&#1091;&#1087;&#1085;&#1072; &#1074;&#1077;&#1088;&#1089;&#1080;&#1103; %1$s.</string>
<string name="update_check_failed">Не удалось проверить обновления.</string> <string name="update_check_failed">Не удалось проверить обновления.</string>
<string name="update_download_failed">Не удалось скачать обновление.</string> <string name="update_download_failed">Не удалось скачать обновление.</string>
<string name="allow_update_install">Разрешите установку из этого источника и повторите установку обновления.</string> <string name="allow_update_install">Разрешите установку из этого источника и повторите установку обновления.</string>
+210 -78
View File
@@ -1,99 +1,231 @@
param( 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]$ArgusBaseUrl = $(if ($env:ARGUS_BASE_URL) { $env:ARGUS_BASE_URL } else { "https://argus.kusoft.xyz" }),
[string]$Username = $env:ARGUS_USERNAME, [string]$ArgusData = $(if ($env:ARGUS_DATA) { $env:ARGUS_DATA } else { "/srv/argus-data" }),
[string]$Password = $env:ARGUS_PASSWORD,
[string]$ApkPath = "keeperApp\build\outputs\apk\debug\keeperApp-debug.apk", [string]$ApkPath = "keeperApp\build\outputs\apk\debug\keeperApp-debug.apk",
[string]$Slug = "keeper-android", [string]$Slug = "keeper-android",
[string]$Name = "QKeep", [string]$Name = "QKeep",
[string]$Summary = "Cloud notes app for QSfera with local storage and sync.", [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]$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]$AndroidPackageName = "eu.qsfera.keeper", [string]$RepositoryUrl = "",
[string]$Version = "0.1.21", [string]$HomepageUrl = "",
[int]$AndroidVersionCode = 22, [string]$Version = "0.1.22",
[string]$Channel = "stable", [string]$Channel = "stable",
[string]$Platform = "android", [string]$Platform = "android",
[string]$PackageKind = "apk", [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" $ErrorActionPreference = "Stop"
if ([string]::IsNullOrWhiteSpace($Username) -or [string]::IsNullOrWhiteSpace($Password)) { function ConvertTo-Base64Utf8([string]$Value) {
throw "Set ARGUS_USERNAME and ARGUS_PASSWORD before publishing." 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 $resolvedApk = (Resolve-Path -LiteralPath $ApkPath).Path
$baseUri = [Uri]($ArgusBaseUrl.TrimEnd("/")) $sshTarget = "$SshUser@$SshHost"
$slugEscaped = [Uri]::EscapeDataString($Slug) $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() $nameB64 = ConvertTo-Base64Utf8 $Name
$handler.CookieContainer = [System.Net.CookieContainer]::new() $summaryB64 = ConvertTo-Base64Utf8 $Summary
$client = [System.Net.Http.HttpClient]::new($handler) $descriptionB64 = ConvertTo-Base64Utf8 $Description
$client.BaseAddress = $baseUri $repositoryUrlB64 = ConvertTo-Base64Utf8 $RepositoryUrl
$homepageUrlB64 = ConvertTo-Base64Utf8 $HomepageUrl
$notesB64 = ConvertTo-Base64Utf8 $Notes
function New-JsonContent([object]$Value) { $remoteScript = @"
$json = $Value | ConvertTo-Json -Depth 8 set -euo pipefail
return [System.Net.Http.StringContent]::new($json, [Text.Encoding]::UTF8, "application/json")
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) { Write-Output "Published $Slug $Version to $manifestUrl"
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()
}