Обновление приложения до 2.26
Android CI / verify (push) Canceled after 0s

This commit is contained in:
Курнат Андрей
2026-07-16 18:46:28 +03:00
parent 9c8d67c18f
commit 2f40569031
500 changed files with 7499 additions and 3684 deletions
+348
View File
@@ -0,0 +1,348 @@
param(
[string]$ManifestPath,
[string]$SshHost = "192.168.0.185",
[string]$SshUser = "sevenhill",
[string]$ArgusDataPath = "/srv/argus-data",
[switch]$RestartArgus
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Resolve-ProjectRoot {
return (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
}
function Resolve-LatestManifestPath {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectRoot
)
$argusRoot = Join-Path (Join-Path $ProjectRoot "artifacts") "argus"
if (-not (Test-Path -LiteralPath $argusRoot)) {
throw "Argus artifacts directory not found: $argusRoot"
}
$latest = Get-ChildItem -LiteralPath $argusRoot -Recurse -Filter "argus-release.json" -File |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if (-not $latest) {
throw "No argus-release.json files found under $argusRoot"
}
return $latest.FullName
}
function Assert-RequiredString {
param(
[Parameter(Mandatory = $true)]
[object]$Manifest,
[Parameter(Mandatory = $true)]
[string]$PropertyName
)
if (-not ($Manifest.PSObject.Properties.Name -contains $PropertyName)) {
throw "Argus manifest is missing required property: $PropertyName"
}
$value = [string]$Manifest.$PropertyName
if ([string]::IsNullOrWhiteSpace($value)) {
throw "Argus manifest property is blank: $PropertyName"
}
return $value
}
function ConvertTo-BashSingleQuoted {
param([AllowNull()][string]$Value)
if ($null -eq $Value) {
return "''"
}
return "'" + ($Value -replace "'", "'\''") + "'"
}
function Get-PublicCatalogFlag {
param([object]$Manifest)
if ($Manifest.PSObject.Properties.Name -contains "publicCatalog") {
return if ([bool]$Manifest.publicCatalog) { "1" } else { "0" }
}
return "1"
}
function Invoke-CheckedNativeCommand {
param(
[Parameter(Mandatory = $true)]
[string]$FilePath,
[Parameter(Mandatory = $true)]
[string[]]$Arguments
)
& $FilePath @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$FilePath exited with code $LASTEXITCODE."
}
}
$projectRoot = Resolve-ProjectRoot
$resolvedManifestPath = if ($ManifestPath) {
(Resolve-Path -LiteralPath $ManifestPath).Path
}
else {
Resolve-LatestManifestPath -ProjectRoot $projectRoot
}
$manifestDirectory = Split-Path -Parent $resolvedManifestPath
$manifest = Get-Content -LiteralPath $resolvedManifestPath -Raw | ConvertFrom-Json
$slug = Assert-RequiredString -Manifest $manifest -PropertyName "slug"
$name = Assert-RequiredString -Manifest $manifest -PropertyName "name"
$summary = Assert-RequiredString -Manifest $manifest -PropertyName "summary"
$description = Assert-RequiredString -Manifest $manifest -PropertyName "description"
$version = Assert-RequiredString -Manifest $manifest -PropertyName "version"
$channel = Assert-RequiredString -Manifest $manifest -PropertyName "channel"
$platform = Assert-RequiredString -Manifest $manifest -PropertyName "platform"
$packageKind = Assert-RequiredString -Manifest $manifest -PropertyName "packageKind"
$packageFile = Assert-RequiredString -Manifest $manifest -PropertyName "packageFile"
$packageSha256 = Assert-RequiredString -Manifest $manifest -PropertyName "packageSha256"
$releaseNotes = if ($manifest.PSObject.Properties.Name -contains "releaseNotes") { [string]$manifest.releaseNotes } else { "" }
$repositoryUrl = if ($manifest.PSObject.Properties.Name -contains "repositoryUrl") { [string]$manifest.repositoryUrl } else { "" }
$homepageUrl = if ($manifest.PSObject.Properties.Name -contains "homepageUrl") { [string]$manifest.homepageUrl } else { "" }
$isListed = Get-PublicCatalogFlag -Manifest $manifest
if ($slug -notmatch "^[a-z0-9][a-z0-9-]{0,99}$") {
throw "Argus slug must contain only lowercase letters, digits, and hyphens, max 100 chars: $slug"
}
if ($packageSha256 -notmatch "^[0-9a-f]{64}$") {
throw "packageSha256 must be a lowercase SHA-256 hex digest: $packageSha256"
}
$packagePath = Join-Path $manifestDirectory $packageFile
if (-not (Test-Path -LiteralPath $packagePath)) {
throw "Package from Argus manifest not found: $packagePath"
}
if ([IO.Path]::GetFileName($packageFile) -ne $packageFile) {
throw "packageFile must be a file name without directories: $packageFile"
}
$actualSha256 = ((Get-FileHash -LiteralPath $packagePath -Algorithm SHA256).Hash).ToLowerInvariant()
if ($actualSha256 -ne $packageSha256) {
throw "Package SHA-256 mismatch. Manifest: $packageSha256 Actual: $actualSha256"
}
$remoteTarget = "$SshUser@$SshHost"
$remoteSourceFile = "/tmp/$packageFile"
Invoke-CheckedNativeCommand -FilePath "scp" -Arguments @(
$packagePath,
"$remoteTarget`:$remoteSourceFile"
)
$pythonBlock = @'
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}")
'@
$remoteScriptLines = @(
"set -euo pipefail",
"export ARGUS_DATA=$(ConvertTo-BashSingleQuoted $ArgusDataPath)",
"export SOURCE_FILE=$(ConvertTo-BashSingleQuoted $remoteSourceFile)",
"export ARGUS_SLUG=$(ConvertTo-BashSingleQuoted $slug)",
"export ARGUS_NAME=$(ConvertTo-BashSingleQuoted $name)",
"export ARGUS_SUMMARY=$(ConvertTo-BashSingleQuoted $summary)",
"export ARGUS_DESCRIPTION=$(ConvertTo-BashSingleQuoted $description)",
"export ARGUS_REPOSITORY_URL=$(ConvertTo-BashSingleQuoted $repositoryUrl)",
"export ARGUS_HOMEPAGE_URL=$(ConvertTo-BashSingleQuoted $homepageUrl)",
"export ARGUS_IS_LISTED=$(ConvertTo-BashSingleQuoted $isListed)",
"export ARGUS_VERSION=$(ConvertTo-BashSingleQuoted $version)",
"export ARGUS_CHANNEL=$(ConvertTo-BashSingleQuoted $channel)",
"export ARGUS_PLATFORM=$(ConvertTo-BashSingleQuoted $platform)",
"export ARGUS_PACKAGE_KIND=$(ConvertTo-BashSingleQuoted $packageKind)",
"export ARGUS_NOTES=$(ConvertTo-BashSingleQuoted $releaseNotes)",
'python3 - "$SOURCE_FILE" <<''PY''',
$pythonBlock,
"PY",
'curl -sS "http://127.0.0.1:5105/api/apps/$ARGUS_SLUG/manifest?platform=$ARGUS_PLATFORM&channel=$ARGUS_CHANNEL"',
""
)
if ($RestartArgus) {
$remoteScriptLines += "docker restart argus"
}
$remoteScript = $remoteScriptLines -join [Environment]::NewLine
$tempRemoteScript = New-TemporaryFile
try {
Set-Content -LiteralPath $tempRemoteScript -Value $remoteScript -Encoding utf8
Get-Content -LiteralPath $tempRemoteScript -Raw | & ssh $remoteTarget "bash" "-s"
if ($LASTEXITCODE -ne 0) {
throw "ssh exited with code $LASTEXITCODE."
}
}
finally {
Remove-Item -LiteralPath $tempRemoteScript -Force -ErrorAction SilentlyContinue
}
$publicManifestUrl = "https://argus.kusoft.xyz/api/apps/$slug/manifest?platform=$platform&channel=$channel"
$publicManifest = Invoke-RestMethod -Uri $publicManifestUrl -TimeoutSec 30
if ([string]$publicManifest.release.version -ne $version) {
throw "Public Argus manifest version mismatch. Expected $version, got $($publicManifest.release.version)."
}
if ([string]$publicManifest.release.sha256 -ne $packageSha256) {
throw "Public Argus manifest SHA-256 mismatch. Expected $packageSha256, got $($publicManifest.release.sha256)."
}
Write-Host "Argus publication verified:"
Write-Host "Manifest: $publicManifestUrl"
Write-Host "Version: $($publicManifest.release.version)"
Write-Host "SHA-256: $($publicManifest.release.sha256)"
+111
View File
@@ -0,0 +1,111 @@
$ErrorActionPreference = "Stop"
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..")
$layoutRoot = Join-Path $repoRoot "app/src/main/res/layout"
$valuesRoot = Join-Path $repoRoot "app/src/main/res/values"
$themesPath = Join-Path $valuesRoot "themes.xml"
$failures = New-Object System.Collections.Generic.List[string]
function Add-Failure([string] $message) {
$failures.Add($message) | Out-Null
}
function Get-XmlLine([string] $content, [int] $index) {
if ($index -lt 0) {
return 1
}
return (($content.Substring(0, $index) -split "`n").Count)
}
function Get-AttributeValue([string] $block, [string] $attributeName) {
$escapedName = [regex]::Escape($attributeName)
$match = [regex]::Match($block, "$escapedName=""([^""]+)""")
if ($match.Success) {
return $match.Groups[1].Value
}
return $null
}
function Assert-MinDp([string] $value, [int] $minimum, [string] $where, [string] $attributeName) {
if ([string]::IsNullOrWhiteSpace($value)) {
Add-Failure "$where is missing $attributeName"
return
}
if ($value -notmatch "^([0-9]+)dp$") {
Add-Failure "$where has non-fixed $attributeName='$value'"
return
}
$actual = [int] $Matches[1]
if ($actual -lt $minimum) {
Add-Failure "$where has $attributeName=${actual}dp, expected at least ${minimum}dp"
}
}
$xmlFiles = Get-ChildItem -Path @($layoutRoot, $valuesRoot) -Filter "*.xml" -Recurse
foreach ($file in $xmlFiles) {
$content = Get-Content $file.FullName -Raw -Encoding UTF8
$relativePath = Resolve-Path -Relative $file.FullName
$textSizeDpMatches = [regex]::Matches($content, 'android:textSize="[0-9.]+dp"')
foreach ($match in $textSizeDpMatches) {
$line = Get-XmlLine $content $match.Index
Add-Failure "${relativePath}:$line uses dp for textSize; use sp"
}
$materialButtons = [regex]::Matches($content, '(?s)<com\.google\.android\.material\.button\.MaterialButton\b.*?/>')
foreach ($match in $materialButtons) {
$line = Get-XmlLine $content $match.Index
$where = "${relativePath}:$line MaterialButton"
$block = $match.Value
if ($block -match 'android:minWidth="0dp"') {
Add-Failure "$where resets minWidth to 0dp"
}
$minHeight = Get-AttributeValue $block "android:minHeight"
if (-not [string]::IsNullOrWhiteSpace($minHeight)) {
Assert-MinDp $minHeight 48 $where "android:minHeight"
}
}
$imageButtons = [regex]::Matches($content, '(?s)<ImageButton\b.*?/>')
foreach ($match in $imageButtons) {
$line = Get-XmlLine $content $match.Index
$where = "${relativePath}:$line ImageButton"
$block = $match.Value
Assert-MinDp (Get-AttributeValue $block "android:layout_width") 48 $where "android:layout_width"
Assert-MinDp (Get-AttributeValue $block "android:layout_height") 48 $where "android:layout_height"
}
}
$themesContent = Get-Content $themesPath -Raw -Encoding UTF8
foreach ($styleName in @("Primary", "Secondary", "Tonal")) {
$stylePattern = "(?s)<style\s+name=""Widget\.Aletheia\.Button\.$styleName"".*?</style>"
$styleMatch = [regex]::Match($themesContent, $stylePattern)
if (-not $styleMatch.Success) {
Add-Failure "themes.xml is missing Widget.Aletheia.Button.$styleName"
continue
}
$heightMatch = [regex]::Match($styleMatch.Value, '<item\s+name="android:minHeight">([0-9]+)dp</item>')
if (-not $heightMatch.Success) {
Add-Failure "Widget.Aletheia.Button.$styleName is missing android:minHeight"
continue
}
$height = [int] $heightMatch.Groups[1].Value
if ($height -lt 48) {
Add-Failure "Widget.Aletheia.Button.$styleName has android:minHeight=${height}dp, expected at least 48dp"
}
}
if ($failures.Count -gt 0) {
Write-Error ("Accessibility layout smoke test failed:`n" + ($failures -join "`n"))
}
Write-Host "Accessibility layout smoke test passed."
+129
View File
@@ -0,0 +1,129 @@
param(
[string]$ManifestPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
function Resolve-ProjectRoot {
return (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
}
function Resolve-LatestManifestPath {
param(
[Parameter(Mandatory = $true)]
[string]$ProjectRoot
)
$argusRoot = Join-Path (Join-Path $ProjectRoot "artifacts") "argus"
if (-not (Test-Path -LiteralPath $argusRoot)) {
throw "Argus artifacts directory not found: $argusRoot"
}
$manifests = Get-ChildItem -LiteralPath $argusRoot -Recurse -Filter "argus-release.json" -File |
ForEach-Object {
$versionText = Split-Path -Leaf (Split-Path -Parent $_.FullName)
$version = $null
if (-not [version]::TryParse($versionText, [ref]$version)) {
throw "Argus manifest directory is not a version: $($_.FullName)"
}
[pscustomobject]@{
Version = $version
Path = $_.FullName
}
} |
Sort-Object Version -Descending
$latest = $manifests | Select-Object -First 1
if (-not $latest) {
throw "No argus-release.json files found under $argusRoot"
}
return $latest.Path
}
function Assert-RequiredString {
param(
[Parameter(Mandatory = $true)]
[object]$Manifest,
[Parameter(Mandatory = $true)]
[string]$PropertyName
)
if (-not ($Manifest.PSObject.Properties.Name -contains $PropertyName)) {
throw "Argus manifest is missing required property: $PropertyName"
}
$value = [string]$Manifest.$PropertyName
if ([string]::IsNullOrWhiteSpace($value)) {
throw "Argus manifest property is blank: $PropertyName"
}
return $value
}
$projectRoot = Resolve-ProjectRoot
$resolvedManifestPath = if ($ManifestPath) {
(Resolve-Path -LiteralPath $ManifestPath).Path
}
else {
Resolve-LatestManifestPath -ProjectRoot $projectRoot
}
$manifestDirectory = Split-Path -Parent $resolvedManifestPath
$manifest = Get-Content -LiteralPath $resolvedManifestPath -Raw | ConvertFrom-Json
$slug = Assert-RequiredString -Manifest $manifest -PropertyName "slug"
$name = Assert-RequiredString -Manifest $manifest -PropertyName "name"
$version = Assert-RequiredString -Manifest $manifest -PropertyName "version"
$channel = Assert-RequiredString -Manifest $manifest -PropertyName "channel"
$platform = Assert-RequiredString -Manifest $manifest -PropertyName "platform"
$packageKind = Assert-RequiredString -Manifest $manifest -PropertyName "packageKind"
$packageFile = Assert-RequiredString -Manifest $manifest -PropertyName "packageFile"
$packageSha256 = Assert-RequiredString -Manifest $manifest -PropertyName "packageSha256"
$signerInfo = Assert-RequiredString -Manifest $manifest -PropertyName "signerInfo"
if ($platform -ne "android") {
throw "Expected platform android, got: $platform"
}
if ($packageKind -ne "apk") {
throw "Expected packageKind apk, got: $packageKind"
}
if ($packageFile -notmatch '\.apk$') {
throw "packageFile must point to an APK: $packageFile"
}
if ($packageSha256 -notmatch '^[0-9a-f]{64}$') {
throw "packageSha256 must be a lowercase SHA-256 hex digest: $packageSha256"
}
if ($signerInfo -notmatch "Signer #1 certificate SHA-256 digest:") {
throw "signerInfo does not contain signer SHA-256 digest."
}
$pathVersion = Split-Path -Leaf $manifestDirectory
if ($version -ne $pathVersion) {
throw "Manifest version '$version' does not match artifact directory '$pathVersion'."
}
$apkPath = Join-Path $manifestDirectory $packageFile
if (-not (Test-Path -LiteralPath $apkPath)) {
throw "APK from Argus manifest not found: $apkPath"
}
$idsigPath = $apkPath + ".idsig"
if (-not (Test-Path -LiteralPath $idsigPath)) {
throw "APK idsig file not found: $idsigPath"
}
$actualSha256 = ((Get-FileHash -LiteralPath $apkPath -Algorithm SHA256).Hash).ToLowerInvariant()
if ($actualSha256 -ne $packageSha256) {
throw "APK SHA-256 mismatch. Manifest: $packageSha256 Actual: $actualSha256"
}
Write-Host "Argus manifest smoke test passed:"
Write-Host "Manifest: $resolvedManifestPath"
Write-Host "Slug: $slug"
Write-Host "Name: $name"
Write-Host "Version: $version"
Write-Host "Channel: $channel"
Write-Host "Package: $apkPath"
Write-Host "SHA-256: $actualSha256"