feat(host-agent): Auto-Update fuer den Android-Agent ueber den RVS

Agent fragt beim Verbinden und alle ~6 h per agent_update_check nach; der
RVS antwortet mit agent_update_available, wenn in rvs/updates/agent/ eine
neuere APK liegt (eigener Ordner — die Cockpit-App bekommt sie so nicht
angeboten). Download im Hintergrund per HTTP (/update/agent/latest.apk,
Raum-Token im Header, 3 Versuche, Groessen-Check), dann Benachrichtigung
"Tippen zum Installieren" + Knopf in der App. Installieren per FileProvider
und Android-Installer (REQUEST_INSTALL_PACKAGES; ein Tippen bleibt Pflicht).
host_hello meldet jetzt die Version. release_agent.sh kopiert die APK auf den
RVS, wenn RVS_UPDATE_HOST gesetzt ist.

RVS: APK-Auslieferung als serveApk() fuer beide Apps zusammengefasst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 13:19:54 +01:00
co-authored by Claude Opus 5.5
parent c9988e7244
commit 0acf96860e
10 changed files with 309 additions and 41 deletions
+16
View File
@@ -268,3 +268,19 @@ einer `.env`; das Kennwort wird interaktiv abgefragt. **Binaries landen unter
> und `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` (steuern);
> `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern
> (z.B. E-Mail-Konto einrichten). Nächstes: Feinschliff (M4).
## Auto-Update
Der Agent fragt beim Verbinden (und danach alle ~6 h) den RVS nach einer neueren
Version (`agent_update_check`). Liegt auf dem RVS eine neuere APK in
`rvs/updates/agent/`, lädt er sie im Hintergrund per HTTP
(`/update/agent/latest.apk`, Token im Header) und zeigt
**„ARIA Host-Agent x.y bereit — Tippen zum Installieren"** — zusätzlich einen
Knopf in der App. Den Install-Dialog zeigt Android selbst; still installieren
darf eine normale App nicht. Beim ersten Mal fragt Android, ob die App
„unbekannte Apps installieren" darf.
`release_agent.sh` kopiert die APK automatisch auf den RVS, wenn `RVS_UPDATE_HOST`
gesetzt ist (wie bei `release.sh`). Manuell:
`scp aria-android-agent.apk <rvs-host>:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v<version>.apk`
— die Version muss im Dateinamen stehen.
@@ -9,6 +9,8 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<!-- Auto-Update: neue Agent-APK vom RVS installieren (Android fragt trotzdem nach) -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<!-- Overlay: ARIA blendet Text/Bild UEBER das laufende Programm ein (Fire TV).
@@ -60,6 +62,17 @@
android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Auto-Update: geladene APK an den Android-Installer uebergeben -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".AgentService"
android:exported="false"
@@ -32,6 +32,11 @@ class AgentService : Service() {
@Volatile var status: String = "gestoppt"
@Volatile var connected: Boolean = false
/** App-Oberflaeche auffrischen (z.B. "Update bereit"-Knopf) */
fun broadcastStatus(ctx: Context) {
ctx.sendBroadcast(Intent(ACTION_STATUS).setPackage(ctx.packageName))
}
fun start(ctx: Context) {
val i = Intent(ctx, AgentService::class.java)
if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i)
@@ -0,0 +1,145 @@
package de.hackersoft.ariaagent
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationCompat
import androidx.core.content.FileProvider
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.util.concurrent.TimeUnit
/**
* Auto-Update fuer den Host-Agent (Android / Fire TV).
*
* 1. RvsClient fragt beim Verbinden (und alle paar Stunden) per
* agent_update_check nach; der RVS antwortet mit agent_update_available.
* 2. Wir laden die APK im Hintergrund per HTTP vom RVS
* (/update/agent/latest.apk, Token im Header) — sie ist nur ein paar MB.
* 3. Dann eine Benachrichtigung "Tippen zum Installieren" (und ein Knopf in
* der App). Den Install-Dialog zeigt Android selbst — still installieren
* darf eine normale App nicht, ein Tippen braucht es immer.
*/
object AgentUpdater {
private const val CH = "aria_agent_update"
private const val NOTIF_ID = 4711
private const val DIR = "updates"
/** Fertig geladene, noch nicht installierte Version ("" = keine) */
@Volatile var readyVersion: String = ""
private set
@Volatile private var downloading = false
private val http = OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS) // Abbruch nur, wenn der Download haengt
.build()
fun installedVersion(ctx: Context): String = try {
ctx.packageManager.getPackageInfo(ctx.packageName, 0).versionName ?: "0"
} catch (_: Exception) { "0" }
/** a > b ? (Versionen wie 0.0.1.6) */
fun isNewer(a: String, b: String): Boolean {
val pa = a.split('.').map { it.toIntOrNull() ?: 0 }
val pb = b.split('.').map { it.toIntOrNull() ?: 0 }
for (i in 0 until maxOf(pa.size, pb.size)) {
val d = pa.getOrElse(i) { 0 } - pb.getOrElse(i) { 0 }
if (d != 0) return d > 0
}
return false
}
private fun apkFile(ctx: Context, version: String) =
File(File(ctx.cacheDir, DIR).apply { mkdirs() }, "aria-host-agent-v$version.apk")
/** RVS meldet eine neue Version → im Hintergrund laden, dann melden. */
fun onAvailable(ctx: Context, config: AgentConfig, version: String, size: Long) {
val app = ctx.applicationContext
if (!isNewer(version, installedVersion(app))) return
val file = apkFile(app, version)
if (readyVersion == version && file.exists()) { notifyReady(app, version); return }
if (downloading) return
downloading = true
Thread {
try {
// Reste frueherer Versionen wegraeumen
File(app.cacheDir, DIR).listFiles()?.forEach { if (it != file) it.delete() }
val proto = if (config.tls) "https" else "http"
val req = Request.Builder()
.url("$proto://${config.host}:${config.port}/update/agent/latest.apk")
.header("X-RVS-Token", config.token)
.build()
var lastErr = ""
for (attempt in 1..3) {
try {
http.newCall(req).execute().use { res ->
if (!res.isSuccessful) throw IllegalStateException("HTTP ${res.code}")
val body = res.body ?: throw IllegalStateException("leere Antwort")
val expected = body.contentLength()
val tmp = File(file.path + ".part")
body.byteStream().use { input -> tmp.outputStream().use { input.copyTo(it) } }
if (expected > 0 && tmp.length() != expected) {
throw IllegalStateException("unvollstaendig (${tmp.length()} von $expected)")
}
if (!tmp.renameTo(file)) throw IllegalStateException("Speichern fehlgeschlagen")
}
readyVersion = version
notifyReady(app, version)
AgentService.broadcastStatus(app)
return@Thread
} catch (e: Exception) {
lastErr = e.message ?: e.toString()
try { Thread.sleep(3000L * attempt) } catch (_: InterruptedException) { return@Thread }
}
}
android.util.Log.w("AgentUpdater", "Update $version nicht geladen: $lastErr")
} finally {
downloading = false
}
}.start()
}
/** Install-Dialog von Android oeffnen (aus Benachrichtigung oder App-Knopf). */
fun install(ctx: Context): Boolean {
val v = readyVersion
if (v.isEmpty()) return false
val file = apkFile(ctx, v)
if (!file.exists()) { readyVersion = ""; return false }
// Android 8+: "Unbekannte Apps installieren" muss fuer DIESE App erlaubt sein
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !ctx.packageManager.canRequestPackageInstalls()) {
ctx.startActivity(Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${ctx.packageName}")).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
return true
}
val uri = FileProvider.getUriForFile(ctx, "${ctx.packageName}.fileprovider", file)
ctx.startActivity(Intent(Intent.ACTION_VIEW)
.setDataAndType(uri, "application/vnd.android.package-archive")
.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK))
return true
}
private fun notifyReady(ctx: Context, version: String) {
val nm = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
nm.createNotificationChannel(NotificationChannel(CH, "Updates", NotificationManager.IMPORTANCE_DEFAULT))
}
val open = PendingIntent.getActivity(ctx, 1,
Intent(ctx, MainActivity::class.java).setAction(MainActivity.ACTION_INSTALL_UPDATE)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
nm.notify(NOTIF_ID, NotificationCompat.Builder(ctx, CH)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle("ARIA Host-Agent $version bereit")
.setContentText("Tippen zum Installieren")
.setContentIntent(open)
.setAutoCancel(true)
.build())
}
}
@@ -26,6 +26,11 @@ import org.json.JSONObject
class MainActivity : AppCompatActivity() {
companion object {
/** Aus der "Update bereit"-Benachrichtigung: gleich den Install-Dialog oeffnen */
const val ACTION_INSTALL_UPDATE = "de.hackersoft.ariaagent.INSTALL_UPDATE"
}
private lateinit var host: EditText
private lateinit var port: EditText
private lateinit var token: EditText
@@ -78,6 +83,10 @@ class MainActivity : AppCompatActivity() {
tls = findViewById(R.id.tls)
control = findViewById(R.id.control)
statusView = findViewById(R.id.status)
findViewById<Button>(R.id.btnUpdate).setOnClickListener {
if (!AgentUpdater.install(this)) toast("Kein Update bereit")
}
if (intent?.action == ACTION_INSTALL_UPDATE) AgentUpdater.install(this)
findViewById<Button>(R.id.btnScan).setOnClickListener {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
@@ -209,13 +218,19 @@ class MainActivity : AppCompatActivity() {
private fun refreshStatus() {
val dot = if (AgentService.connected) "🟢" else "🔴"
statusView.text = "Status: ${AgentService.status} $dot"
statusView.text = "Status: ${AgentService.status} $dot · v${AgentUpdater.installedVersion(this)}"
val ready = AgentUpdater.readyVersion
findViewById<Button>(R.id.btnUpdate).apply {
visibility = if (ready.isNotEmpty()) android.view.View.VISIBLE else android.view.View.GONE
text = "Update auf $ready installieren"
}
}
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
if (intent == null) return
setIntent(intent)
if (intent.action == ACTION_INSTALL_UPDATE) { AgentUpdater.install(this); return }
applyProvisioning(intent)
}
@@ -87,6 +87,7 @@ class RvsClient(
override fun onOpen(webSocket: WebSocket, response: Response) {
onStatus(true, "verbunden")
sendHello(webSocket)
sendUpdateCheck(webSocket)
startPing(webSocket)
}
override fun onMessage(webSocket: WebSocket, text: String) {
@@ -141,9 +142,17 @@ class RvsClient(
localIp()?.let { p.put("ip", it) }
p.put("caps", JSONArray(caps))
p.put("control", config.controlEnabled)
p.put("version", AgentUpdater.installedVersion(appCtx))
send(webSocket, "host_hello", p)
}
/** Beim RVS nach einer neueren Agent-Version fragen (Antwort: agent_update_available) */
private fun sendUpdateCheck(webSocket: WebSocket) {
send(webSocket, "agent_update_check", JSONObject()
.put("version", AgentUpdater.installedVersion(appCtx))
.put("platform", "android"))
}
/** Erste echte IPv4 dieses Geraets im LAN (ohne Loopback). */
private fun localIp(): String? = try {
java.net.NetworkInterface.getNetworkInterfaces().toList()
@@ -153,6 +162,8 @@ class RvsClient(
?.hostAddress
} catch (_: Throwable) { null }
private var pingCount = 0
private fun startPing(webSocket: WebSocket) {
pingThread?.interrupt()
pingThread = Thread {
@@ -161,6 +172,8 @@ class RvsClient(
val p = JSONObject().put("hostId", config.hostId())
send(webSocket, "host_ping", p)
sendHello(webSocket) // Re-announce (RVS replayt hellos nicht)
// Alle ~6 h nach Updates fragen (25 s × 864)
if (++pingCount % 864 == 0) sendUpdateCheck(webSocket)
}
}.also { it.start() }
}
@@ -171,6 +184,11 @@ class RvsClient(
// Sprachdialog: chat/audio_pcm/stt_* laufen ueber dieselbe Verbindung.
// (Der RVS broadcastet alles im Raum — VoiceSession filtert auf den
// eigenen Turn, damit nicht auch Handy und Stick gleichzeitig antworten.)
if (type == "agent_update_available") {
val p = msg.optJSONObject("payload") ?: JSONObject()
AgentUpdater.onAvailable(appCtx, config, p.optString("version"), p.optLong("size"))
return
}
if (type != "host_command") {
if (type in VOICE_TYPES) {
try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) }
@@ -25,6 +25,15 @@
android:text="Status: —"
android:paddingBottom="16dp" />
<Button
android:id="@+id/btnUpdate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:focusable="true"
android:focusableInTouchMode="false"
android:visibility="gone"
android:text="Update installieren" />
<Button
android:id="@+id/btnScan"
android:layout_width="match_parent"
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Auto-Update: nur der Update-Ordner im Cache wird an den Installer freigegeben -->
<paths>
<cache-path name="updates" path="updates/" />
</paths>
+15
View File
@@ -143,6 +143,21 @@ for entry in "${ASSETS[@]}"; do
fi
done
# ── Auto-Update: Android-APK auf den RVS (eigener Ordner updates/agent/) ──
RVS_UPDATE_HOST="${RVS_UPDATE_HOST:-}"
if [ -n "$RVS_UPDATE_HOST" ]; then
echo -e "\n${GREEN}[+] Android-APK auf den RVS kopieren (Auto-Update)...${NC}"
if ssh "$RVS_UPDATE_HOST" "mkdir -p ~/ARIA-AGENT/rvs/updates/agent && rm -f ~/ARIA-AGENT/rvs/updates/agent/*.apk" 2>/dev/null \
&& scp "$APK" "${RVS_UPDATE_HOST}:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v${VERSION}.apk" 2>/dev/null; then
echo -e " ${GREEN}✓${NC} Agents bekommen v$VERSION beim naechsten Verbinden angeboten"
else
echo -e " ${YELLOW}Kopieren fehlgeschlagen (RVS_UPDATE_HOST=$RVS_UPDATE_HOST). Manuell:${NC}"
echo -e " ${YELLOW}scp $APK $RVS_UPDATE_HOST:~/ARIA-AGENT/rvs/updates/agent/aria-host-agent-v${VERSION}.apk${NC}"
fi
else
echo -e "\n${YELLOW}Auto-Update uebersprungen (RVS_UPDATE_HOST nicht gesetzt)${NC}"
fi
echo ""
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
+67 -40
View File
@@ -11,6 +11,9 @@ const path = require("path");
const PORT = parseInt(process.env.PORT || "3000", 10);
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10);
const UPDATES_DIR = process.env.UPDATES_DIR || "/updates";
// Host-Agent (Android) hat einen EIGENEN Ordner — sonst bekaeme die Cockpit-App
// die Agent-APK angeboten (die Versions-Erkennung schaut nur auf den Dateinamen).
const AGENT_UPDATES_DIR = path.join(UPDATES_DIR, "agent");
// HTTPS statt HTTP auf PORT — fuer die Strecke externer Reverse-Proxy → RVS.
// Das oeffentliche Zertifikat hat der Proxy; hier reicht ein selbstsigniertes
// (wird beim ersten Start erzeugt). NICHT zusammen mit dem Caddy im Stack
@@ -148,6 +151,8 @@ const ALLOWED_TYPES = new Set([
// Wer ist im Raum? client_hello: Client stellt sich vor (bleibt beim RVS);
// room_clients_query → room_clients: Liste NUR des eigenen Raums.
"client_hello", "room_clients_query", "room_clients",
// Host-Agent (Android): Update-Pruefung, beantwortet der RVS selbst
"agent_update_check", "agent_update_available",
"node_stats", "node_stats_stream_start", "node_stats_stream_stop",
"node_stats_history_request", "node_stats_history",
"node_stats_reset", "node_stats_reset_done",
@@ -403,48 +408,20 @@ function handleHttpRequest(req, res) {
return;
}
// App-Update als normaler HTTP-Download (statt eines riesigen WS-Blocks):
// Content-Length fuer die Fortschrittsanzeige, Range zum Fortsetzen.
// Nur fuer Geraete mit gueltigem Raum-Token (Header X-RVS-Token).
if ((req.method === "GET" || req.method === "HEAD") && pathname === "/update/latest.apk") {
// Updates als normaler HTTP-Download (statt eines riesigen WS-Blocks):
// Cockpit-App unter /update/latest.apk, Host-Agent (Android) unter
// /update/agent/latest.apk. Nur mit gueltigem Raum-Token (X-RVS-Token).
if ((req.method === "GET" || req.method === "HEAD")
&& (pathname === "/update/latest.apk" || pathname === "/update/agent/latest.apk")) {
const tok = String(req.headers["x-rvs-token"] || "");
if (!tok || !rooms.has(tok)) {
res.writeHead(403, { "Content-Type": "text/plain" });
res.end("Forbidden\n");
return;
}
const apk = getLatestAPK();
if (!apk) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Keine APK verfuegbar\n");
return;
}
const size = fs.statSync(apk.path).size;
const headers = {
"Content-Type": "application/vnd.android.package-archive",
"Content-Disposition": `attachment; filename="ARIA-v${apk.version}.apk"`,
"Accept-Ranges": "bytes",
"Cache-Control": "no-store",
"X-APK-Version": apk.version,
};
let start = 0, end = size - 1, status = 200;
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ""));
if (m && (m[1] || m[2])) {
if (m[1]) { start = parseInt(m[1], 10); if (m[2]) end = Math.min(parseInt(m[2], 10), size - 1); }
else { start = Math.max(0, size - parseInt(m[2], 10)); }
if (start > end || start >= size) {
res.writeHead(416, { "Content-Range": `bytes */${size}` });
res.end();
return;
}
status = 206;
headers["Content-Range"] = `bytes ${start}-${end}/${size}`;
}
headers["Content-Length"] = end - start + 1;
res.writeHead(status, headers);
if (req.method === "HEAD") { res.end(); return; }
log(`APK-Download (HTTP) v${apk.version}${status === 206 ? ` ab Byte ${start}` : ""}`);
fs.createReadStream(apk.path, { start, end }).pipe(res);
const isAgent = pathname.startsWith("/update/agent/");
serveApk(req, res, isAgent ? getLatestAPK(AGENT_UPDATES_DIR) : getLatestAPK(),
isAgent ? "aria-host-agent" : "ARIA");
return;
}
@@ -641,6 +618,21 @@ function registerClient(ws, token) {
return;
}
// Host-Agent (Android) fragt nach einem Update — direkt antworten
if (msg.type === "agent_update_check") {
const have = String(msg.payload?.version || "0.0.0.0");
const apk = getLatestAPK(AGENT_UPDATES_DIR);
if (apk && compareVersions(apk.version, have) > 0) {
ws.send(JSON.stringify({
type: "agent_update_available",
payload: { version: apk.version, downloadUrl: "/update/agent/latest.apk",
size: fs.statSync(apk.path).size },
timestamp: Date.now(),
}));
}
return;
}
// Raum-Diagnose: direkt an den anfragenden Client antworten (nicht relay'en).
// Zeigt ALLE Raeume mit token8 (wie im Log), einem laengeren Fingerprint und
// der Token-Laenge — so werden Prefix-Kollisionen (2 Raeume, gleicher 8-Zeichen-
@@ -781,15 +773,50 @@ wss.on("close", () => {
let latestVersion = null;
function getLatestAPK() {
/** APK per HTTP ausliefern: Content-Length (Fortschritt), Range (Fortsetzen). */
function serveApk(req, res, apk, label) {
if (!apk) {
res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Keine APK verfuegbar\n");
return;
}
const size = fs.statSync(apk.path).size;
const headers = {
"Content-Type": "application/vnd.android.package-archive",
"Content-Disposition": `attachment; filename="${label}-v${apk.version}.apk"`,
"Accept-Ranges": "bytes",
"Cache-Control": "no-store",
"X-APK-Version": apk.version,
};
let start = 0, end = size - 1, status = 200;
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ""));
if (m && (m[1] || m[2])) {
if (m[1]) { start = parseInt(m[1], 10); if (m[2]) end = Math.min(parseInt(m[2], 10), size - 1); }
else { start = Math.max(0, size - parseInt(m[2], 10)); }
if (start > end || start >= size) {
res.writeHead(416, { "Content-Range": `bytes */${size}` });
res.end();
return;
}
status = 206;
headers["Content-Range"] = `bytes ${start}-${end}/${size}`;
}
headers["Content-Length"] = end - start + 1;
res.writeHead(status, headers);
if (req.method === "HEAD") { res.end(); return; }
log(`APK-Download (HTTP) ${label} v${apk.version}${status === 206 ? ` ab Byte ${start}` : ""}`);
fs.createReadStream(apk.path, { start, end }).pipe(res);
}
function getLatestAPK(dir = UPDATES_DIR) {
try {
if (!fs.existsSync(UPDATES_DIR)) return null;
const files = fs.readdirSync(UPDATES_DIR)
if (!fs.existsSync(dir)) return null;
const files = fs.readdirSync(dir)
.filter(f => f.endsWith(".apk"))
.map(f => {
// ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk
const match = f.match(/(\d+(?:\.\d+){2,})/);
return { file: f, path: path.join(UPDATES_DIR, f), version: match ? match[1] : null };
return { file: f, path: path.join(dir, f), version: match ? match[1] : null };
})
.filter(f => f.version)
.sort((a, b) => compareVersions(b.version, a.version)); // Neueste zuerst