Align Keeper Argus publication flow
This commit is contained in:
+4
-10
@@ -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`
|
||||||
|
|||||||
@@ -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">Установлена актуальная версия %1$s.</string>
|
||||||
<string name="update_available">Доступна версия %1$s (%2$d).</string>
|
<string name="no_update_available">В Argus нет релиза для этого приложения.</string>
|
||||||
|
<string name="update_available">Доступна версия %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>
|
||||||
|
|||||||
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user