Compare commits

...
6 Commits
Author SHA1 Message Date
duffyduck 55b7a02b85 release: bump version to 0.2.6.1 2026-10-25 13:32:19 +01:00
duffyduckandClaude Opus 5.5 ae2024ef55 feat(app): "Auf Updates pruefen" sagt immer, was los ist
Keine neue Version: Meldung mit installierter Version, neuester Version auf
dem Server und ob Update-Server und Download-Weg erreichbar sind (HEAD auf
/update/latest.apk). Keine Verbindung: Meldung mit Server-Adresse, Zustand
und letztem Verbindungsfehler. Keine Antwort in 10 s: Hinweis auf alten RVS.
Neue Version: wie bisher der Update-Dialog (nur einmal, nicht doppelt ueber
den ChatScreen-Listener).

RVS antwortet auf update_check jetzt auch ohne neue Version (update_none
mit latest/current).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:26:23 +01:00
duffyduck 27f4fbdd12 release: bump version to 0.2.6.0 2026-10-25 13:25:27 +01:00
duffyduckandClaude Opus 5.5 0acf96860e 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>
2026-10-25 13:19:54 +01:00
duffyduck c9988e7244 release: bump version to 0.2.5.9 2026-10-25 13:13:29 +01:00
duffyduckandClaude Opus 5.5 a00199c121 feat(update): App laedt Updates per HTTP mit Fortschrittsanzeige statt als 65-MB-WebSocket-Block
Bisher kam die APK als EINE WebSocket-Nachricht (~65 MB Base64) mit starrem
60-s-Timeout — ueber den externen Proxy (Heim-Upload → Hetzner → Handy) zu
knapp, Abbruch ohne Fortschritt.

RVS: GET/HEAD /update/latest.apk — nur mit gueltigem Raum-Token im Header
X-RVS-Token (sonst 403), Content-Length, Range/206 zum Fortsetzen. Version
im Dateinamen wird ohne angehaengten Punkt erkannt.

App: Download per HTTP (react-native-fs) mit Fortschritt in % und MB,
Abbrechen-Knopf, Abbruch nur bei 30 s ohne Daten statt fester Gesamtzeit,
3 Versuche, Groessen-Check gegen Content-Length. Alter RVS (404): Fallback
auf den WebSocket-Weg mit 5 min Timeout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:10:07 +01:00
17 changed files with 603 additions and 47 deletions
+3 -1
View File
@@ -855,7 +855,9 @@ Satelliten verbinden dann nicht mehr). ACME-State persistent in `./data/caddy/`
- WebSocket Relay (alle Message-Types: chat, audio, file, config, xtts, update, etc.) - WebSocket Relay (alle Message-Types: chat, audio, file, config, xtts, update, etc.)
- OAuth-Callback HTTP: `GET /oauth/callback/{service}?code=...` → broadcastet als - OAuth-Callback HTTP: `GET /oauth/callback/{service}?code=...` → broadcastet als
`oauth_callback`-WS-Message + zeigt dem Browser eine "OAuth erfolgreich"-Seite `oauth_callback`-WS-Message + zeigt dem Browser eine "OAuth erfolgreich"-Seite
- Auto-Update: APK-Verteilung an Apps ueber WebSocket - Auto-Update: App fragt per WebSocket nach der Version, laedt die APK per HTTP
(`/update/latest.apk`, nur mit gueltigem Raum-Token im Header `X-RVS-Token`,
mit Fortschrittsanzeige + Fortsetzen per Range). Alte Apps: Fallback ueber WebSocket
- Heartbeat + tote Verbindungen aufraeumen - Heartbeat + tote Verbindungen aufraeumen
**Auto-Update APK bereitstellen:** **Auto-Update APK bereitstellen:**
+2
View File
@@ -16,6 +16,7 @@ import WorkspaceScreen from './src/workspace/WorkspaceScreen';
import SettingsScreen from './src/screens/SettingsScreen'; import SettingsScreen from './src/screens/SettingsScreen';
import ViewModeToggle from './src/components/ViewModeToggle'; import ViewModeToggle from './src/components/ViewModeToggle';
import SecurityLock from './src/components/SecurityLock'; import SecurityLock from './src/components/SecurityLock';
import UpdateProgress from './src/components/UpdateProgress';
import rvs from './src/services/rvs'; import rvs from './src/services/rvs';
import { initLogger, installGlobalCrashReporter } from './src/services/logger'; import { initLogger, installGlobalCrashReporter } from './src/services/logger';
import { acquireBackgroundAudio } from './src/services/backgroundAudio'; import { acquireBackgroundAudio } from './src/services/backgroundAudio';
@@ -193,6 +194,7 @@ const App: React.FC = () => {
/> />
</Tab.Navigator> </Tab.Navigator>
</NavigationContainer> </NavigationContainer>
<UpdateProgress />
</GestureHandlerRootView> </GestureHandlerRootView>
); );
}; };
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20508 versionCode 20601
versionName "0.2.5.8" versionName "0.2.6.1"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.2.5.8", "version": "0.2.6.1",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+57
View File
@@ -0,0 +1,57 @@
/**
* Fortschrittsanzeige beim App-Update: Balken mit Prozent und MB, Knopf
* zum Abbrechen. Erscheint nur, waehrend ein Update laedt/installiert.
*/
import React, { useEffect, useState } from 'react';
import { Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import updateService, { UpdateProgress as Progress } from '../services/updater';
const UpdateProgress: React.FC = () => {
const [p, setP] = useState<Progress | null>(null);
useEffect(() => updateService.onProgress(setP), []);
if (!p || !p.active) return null;
const known = p.fraction >= 0;
const pct = known ? Math.round(p.fraction * 100) : 0;
return (
<Modal visible transparent animationType="fade" onRequestClose={() => {}}>
<View style={styles.overlay}>
<View style={styles.box}>
<Text style={styles.title}>Update auf Version {p.version}</Text>
<Text style={styles.status}>{p.status}</Text>
<View style={styles.track}>
<View style={[styles.bar, { width: known ? `${pct}%` : '100%' }, !known && styles.barUnknown]} />
</View>
<Text style={styles.numbers}>
{known ? `${pct} % · ` : ''}
{p.receivedMB > 0 ? `${p.receivedMB.toFixed(1)} von ${p.totalMB.toFixed(1)} MB` : `${p.totalMB.toFixed(1)} MB`}
</Text>
{p.status !== 'Installiere …' && (
<TouchableOpacity style={styles.btn} onPress={() => updateService.cancelDownload()}>
<Text style={styles.btnText}>Abbrechen</Text>
</TouchableOpacity>
)}
</View>
</View>
</Modal>
);
};
const styles = StyleSheet.create({
overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', padding: 24 },
box: { backgroundColor: '#1A1A2E', borderRadius: 14, padding: 20 },
title: { color: '#FFFFFF', fontSize: 17, fontWeight: '700' },
status: { color: '#C8C8D8', fontSize: 14, marginTop: 6 },
track: { height: 10, borderRadius: 5, backgroundColor: '#2A2A3E', marginTop: 14, overflow: 'hidden' },
bar: { height: 10, borderRadius: 5, backgroundColor: '#0096FF' },
barUnknown: { opacity: 0.35 },
numbers: { color: '#9090A8', fontSize: 13, marginTop: 8 },
btn: { alignSelf: 'flex-end', marginTop: 14, paddingVertical: 8, paddingHorizontal: 16,
borderRadius: 8, backgroundColor: '#2A2A3E' },
btnText: { color: '#FFFFFF', fontWeight: '600' },
});
export default UpdateProgress;
+1 -2
View File
@@ -2539,8 +2539,7 @@ const SettingsScreen: React.FC = () => {
style={[styles.connectButton, {marginTop: 12}]} style={[styles.connectButton, {marginTop: 12}]}
onPress={() => { onPress={() => {
const updateService = require('../services/updater').default; const updateService = require('../services/updater').default;
updateService.checkForUpdate(); updateService.checkForUpdateManual();
Alert.alert('Update-Check', 'Pruefe auf neue Version...');
}} }}
> >
<Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text> <Text style={styles.connectButtonText}>Auf Updates pr{'\u00FC'}fen</Text>
+209 -34
View File
@@ -17,6 +17,26 @@ const APP_VERSION = packageJson.version || '0.0.0.0';
type UpdateCallback = (info: UpdateInfo) => void; type UpdateCallback = (info: UpdateInfo) => void;
/** Fortschritt fuer die Anzeige (UpdateProgress-Komponente) */
export interface UpdateProgress {
active: boolean;
version: string;
/** 0..1, -1 = unbekannt */
fraction: number;
receivedMB: number;
totalMB: number;
/** z.B. "Lade herunter …", "Neuer Versuch (2/3) …", "Installiere …" */
status: string;
}
type ProgressCallback = (p: UpdateProgress) => void;
const HTTP_ATTEMPTS = 3;
// Abbruch nur, wenn der Download HAENGT (so lange kommt kein Byte) — nicht
// nach einer festen Gesamtzeit wie frueher (60 s fuer 65 MB war zu knapp).
const READ_STALL_MS = 30_000;
const WS_TIMEOUT_MS = 5 * 60_000;
export interface UpdateInfo { export interface UpdateInfo {
version: string; version: string;
downloadUrl: string; downloadUrl: string;
@@ -27,6 +47,13 @@ class UpdateService {
private listeners: UpdateCallback[] = []; private listeners: UpdateCallback[] = [];
private checking = false; private checking = false;
private downloading = false; private downloading = false;
private progressListeners: ProgressCallback[] = [];
private progress: UpdateProgress = { active: false, version: '', fraction: -1, receivedMB: 0, totalMB: 0, status: '' };
private jobId: number | null = null;
/** Laeuft gerade eine Pruefung per Knopf? Dann geht die Antwort NUR dorthin
* (sonst zeigt der ChatScreen-Listener denselben Dialog ein zweites Mal). */
private manualWaiter: ((r: { type: 'available'; info: UpdateInfo } | { type: 'none'; latest: string }) => void) | null = null;
private cancelled = false;
constructor() { constructor() {
// Beim Start alte APK-Reste aus dem Cache wegraeumen — wenn diese App // Beim Start alte APK-Reste aus dem Cache wegraeumen — wenn diese App
@@ -42,10 +69,19 @@ class UpdateService {
downloadUrl: (msg.payload.downloadUrl as string) || '', downloadUrl: (msg.payload.downloadUrl as string) || '',
size: (msg.payload.size as number) || 0, size: (msg.payload.size as number) || 0,
}; };
if (this.manualWaiter) {
const w = this.manualWaiter; this.manualWaiter = null;
if (info.version && this.isNewer(info.version)) w({ type: 'available', info });
else w({ type: 'none', latest: info.version });
return;
}
if (info.version && this.isNewer(info.version)) { if (info.version && this.isNewer(info.version)) {
console.log(`[Update] Neue Version verfuegbar: ${info.version} (aktuell: ${APP_VERSION})`); console.log(`[Update] Neue Version verfuegbar: ${info.version} (aktuell: ${APP_VERSION})`);
this.listeners.forEach(cb => cb(info)); this.listeners.forEach(cb => cb(info));
} }
} else if ((msg.type as string) === 'update_none' && this.manualWaiter) {
const w = this.manualWaiter; this.manualWaiter = null;
w({ type: 'none', latest: String(msg.payload.latest || '') });
} }
}); });
} }
@@ -126,6 +162,64 @@ class UpdateService {
setTimeout(() => { this.checking = false; }, 10000); setTimeout(() => { this.checking = false; }, 10000);
} }
/**
* Pruefung per Knopf (Einstellungen → Ueber): sagt IMMER etwas —
* neue Version (Update-Dialog), "keine neue Version" mit der Version auf dem
* Server, oder warum der Update-Server nicht erreichbar ist (mit Details).
*/
async checkForUpdateManual(): Promise<void> {
const cfg = rvs.getConfig();
const where = cfg ? `${cfg.useTLS ? 'wss' : 'ws'}://${cfg.host}:${cfg.port}` : '(nicht eingerichtet)';
if (!cfg) {
Alert.alert('Update-Server nicht erreichbar', 'Es ist noch kein Server eingerichtet (Einstellungen → Verbindung).');
return;
}
if (!rvs.isSocketOpen()) {
const lastErr = [...rvs.getConnectionLog()].reverse().find(e => e.level === 'error');
Alert.alert('Update-Server nicht erreichbar',
`Keine Verbindung zu ${where}.\n\nZustand: ${rvs.getState()}`
+ (lastErr ? `\nLetzter Fehler (${new Date(lastErr.timestamp).toLocaleTimeString()}):\n${lastErr.message}` : ''));
return;
}
const result = await new Promise<{ type: 'available'; info: UpdateInfo } | { type: 'none'; latest: string } | { type: 'timeout' }>(resolve => {
const t = setTimeout(() => { this.manualWaiter = null; resolve({ type: 'timeout' }); }, 10_000);
this.manualWaiter = (r) => { clearTimeout(t); resolve(r); };
rvs.send('update_check' as any, { version: APP_VERSION });
});
if (result.type === 'available') {
this.promptUpdate(result.info);
return;
}
if (result.type === 'timeout') {
Alert.alert('Update-Server antwortet nicht',
`Verbunden mit ${where}, aber nach 10 s keine Antwort auf die Update-Anfrage.\n\n`
+ 'Moeglicherweise laeuft auf dem Server eine alte RVS-Version.');
return;
}
// Keine neue Version — zusaetzlich pruefen, ob der Download-Weg funktioniert
let dl = '';
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 10_000);
const r = await fetch(`${cfg.useTLS ? 'https' : 'http'}://${cfg.host}:${cfg.port}/update/latest.apk`,
{ method: 'HEAD', headers: { 'X-RVS-Token': cfg.token }, signal: ctrl.signal });
clearTimeout(t);
dl = r.ok ? 'Download-Weg: erreichbar ✓'
: r.status === 404 && result.latest ? 'Download-Weg: vom Server noch nicht unterstuetzt (alter RVS)'
: r.status === 404 ? 'Download-Weg: erreichbar, aber auf dem Server liegt keine APK'
: `Download-Weg: Fehler HTTP ${r.status}`;
} catch (e: any) {
dl = `Download-Weg: nicht erreichbar (${e?.name === 'AbortError' ? 'Zeitueberschreitung' : (e?.message || e)})`;
}
Alert.alert('Keine neue Version',
`Installiert: ${APP_VERSION}\n`
+ `Neueste auf dem Server: ${result.latest || '(keine APK hinterlegt)'}\n\n`
+ `Update-Server: ${where} ✓\n${dl}`);
}
/** Callback registrieren */ /** Callback registrieren */
onUpdateAvailable(callback: UpdateCallback): () => void { onUpdateAvailable(callback: UpdateCallback): () => void {
this.listeners.push(callback); this.listeners.push(callback);
@@ -150,46 +244,125 @@ class UpdateService {
); );
} }
/** APK ueber WebSocket herunterladen und installieren */ onProgress(callback: ProgressCallback): () => void {
this.progressListeners.push(callback);
callback(this.progress);
return () => {
this.progressListeners = this.progressListeners.filter(cb => cb !== callback);
};
}
private setProgress(p: Partial<UpdateProgress>): void {
this.progress = { ...this.progress, ...p };
this.progressListeners.forEach(cb => cb(this.progress));
}
/** Laufenden Download abbrechen (Knopf in der Fortschrittsanzeige) */
cancelDownload(): void {
this.cancelled = true;
if (this.jobId !== null) {
try { RNFS.stopDownload(this.jobId); } catch (_) {}
}
}
/**
* APK per HTTP vom RVS laden (/update/latest.apk, Token im Header).
* Gibt false zurueck, wenn der RVS den Weg noch nicht kennt (alter RVS) —
* dann nimmt downloadAndInstall den alten WebSocket-Weg.
*/
private async downloadViaHttp(info: UpdateInfo, destPath: string): Promise<boolean> {
const cfg = rvs.getConfig();
if (!cfg) return false;
const url = `${cfg.useTLS ? 'https' : 'http'}://${cfg.host}:${cfg.port}/update/latest.apk`;
let lastErr = '';
for (let attempt = 1; attempt <= HTTP_ATTEMPTS; attempt++) {
if (this.cancelled) throw new Error('Abgebrochen');
this.setProgress({ status: attempt === 1 ? 'Lade herunter …' : `Neuer Versuch (${attempt}/${HTTP_ATTEMPTS}) …`,
fraction: -1, receivedMB: 0 });
let expected = 0;
try {
const job = RNFS.downloadFile({
fromUrl: url,
toFile: destPath,
headers: { 'X-RVS-Token': cfg.token },
connectionTimeout: 15_000,
readTimeout: READ_STALL_MS,
progressInterval: 300,
begin: (r) => { expected = r.contentLength > 0 ? r.contentLength : 0; },
progress: (r) => {
const total = r.contentLength > 0 ? r.contentLength : info.size;
this.setProgress({
fraction: total > 0 ? Math.min(1, r.bytesWritten / total) : -1,
receivedMB: r.bytesWritten / 1048576,
totalMB: total / 1048576,
});
},
});
this.jobId = job.jobId;
const res = await job.promise;
this.jobId = null;
if (res.statusCode === 403 || res.statusCode === 404) {
// Alter RVS ohne HTTP-Download (404) bzw. Token unbekannt (403)
console.warn(`[Update] HTTP-Download nicht verfuegbar (${res.statusCode}) — nehme WebSocket`);
return false;
}
if (res.statusCode !== 200) throw new Error(`HTTP ${res.statusCode}`);
const st = await RNFS.stat(destPath);
if (expected && Number(st.size) !== expected) {
throw new Error(`Datei unvollstaendig (${st.size} von ${expected} Bytes)`);
}
return true;
} catch (err: any) {
this.jobId = null;
if (this.cancelled) throw new Error('Abgebrochen');
lastErr = err?.message || String(err);
console.warn(`[Update] HTTP-Download Versuch ${attempt} fehlgeschlagen: ${lastErr}`);
await new Promise<void>(r => setTimeout(r, 2000 * attempt));
}
}
throw new Error(`Download fehlgeschlagen: ${lastErr}`);
}
/** Alter Weg: APK als ein grosser Block ueber die WebSocket-Verbindung */
private async downloadViaWebSocket(info: UpdateInfo, destPath: string): Promise<void> {
this.setProgress({ status: 'Lade herunter (ohne Fortschritt) …', fraction: -1 });
rvs.send('update_download' as any, {});
const base64 = await new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => { unsub(); reject(new Error('Download-Timeout (5 min)')); }, WS_TIMEOUT_MS);
const unsub = rvs.onMessage((msg: RVSMessage) => {
if ((msg.type as string) === 'update_data') {
clearTimeout(timeout);
unsub();
if (msg.payload.error) reject(new Error(msg.payload.error as string));
else resolve(msg.payload.base64 as string);
}
});
});
if (this.cancelled) throw new Error('Abgebrochen');
await RNFS.writeFile(destPath, base64, 'base64');
}
/** APK herunterladen (HTTP mit Fortschritt, sonst WebSocket) und installieren */
async downloadAndInstall(info: UpdateInfo): Promise<void> { async downloadAndInstall(info: UpdateInfo): Promise<void> {
if (this.downloading) return; if (this.downloading) return;
this.downloading = true; this.downloading = true;
this.cancelled = false;
this.setProgress({ active: true, version: info.version, fraction: -1, receivedMB: 0,
totalMB: info.size / 1048576, status: 'Starte …' });
const fileName = `ARIA-v${info.version}.apk`;
const destPath = `${RNFS.CachesDirectoryPath}/${fileName}`;
try { try {
console.log(`[Update] Fordere APK v${info.version} an...`); console.log(`[Update] Lade APK v${info.version} …`);
Alert.alert('Download gestartet', `Version ${info.version} wird ueber RVS heruntergeladen...`); // Vor dem Download alte APKs im Cache wegraeumen
// APK ueber WebSocket anfordern
rvs.send('update_download' as any, {});
// Auf update_data warten (einmalig)
const apkData = await new Promise<{base64: string, fileName: string}>((resolve, reject) => {
const timeout = setTimeout(() => reject(new Error('Download-Timeout (60s)')), 60000);
const unsub = rvs.onMessage((msg: RVSMessage) => {
if ((msg.type as string) === 'update_data') {
clearTimeout(timeout);
unsub();
if (msg.payload.error) {
reject(new Error(msg.payload.error as string));
} else {
resolve({
base64: msg.payload.base64 as string,
fileName: msg.payload.fileName as string || `ARIA-${info.version}.apk`,
});
}
}
});
});
// Vor dem Schreiben alte APKs im Cache wegraeumen — falls mehrere
// Updates in einer Session gezogen werden
await this.cleanupOldApks(); await this.cleanupOldApks();
// Base64 als APK-Datei speichern const viaHttp = await this.downloadViaHttp(info, destPath);
const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`; if (!viaHttp) await this.downloadViaWebSocket(info, destPath);
await RNFS.writeFile(destPath, apkData.base64, 'base64');
const fileSize = await RNFS.stat(destPath); const fileSize = await RNFS.stat(destPath);
console.log(`[Update] APK gespeichert: ${destPath} (${(Number(fileSize.size) / 1024 / 1024).toFixed(1)}MB)`); console.log(`[Update] APK gespeichert: ${destPath} (${(Number(fileSize.size) / 1024 / 1024).toFixed(1)}MB, ${viaHttp ? 'HTTP' : 'WebSocket'})`);
this.setProgress({ fraction: 1, status: 'Installiere …' });
// APK installieren via natives ApkInstaller Module (FileProvider + Intent) // APK installieren via natives ApkInstaller Module (FileProvider + Intent)
if (Platform.OS === 'android') { if (Platform.OS === 'android') {
@@ -199,15 +372,17 @@ class UpdateService {
} catch (installErr: any) { } catch (installErr: any) {
Alert.alert( Alert.alert(
'APK heruntergeladen', 'APK heruntergeladen',
`Version ${info.version} gespeichert.\n\nBitte manuell installieren:\nDateimanager → ${apkData.fileName} antippen.\n\n(${installErr.message})`, `Version ${info.version} gespeichert.\n\nBitte manuell installieren:\nDateimanager → ${fileName} antippen.\n\n(${installErr.message})`,
); );
} }
} }
} catch (err: any) { } catch (err: any) {
console.error(`[Update] Fehler: ${err.message}`); console.error(`[Update] Fehler: ${err.message}`);
Alert.alert('Update fehlgeschlagen', err.message); try { await RNFS.unlink(destPath); } catch (_) {}
if (!this.cancelled) Alert.alert('Update fehlgeschlagen', err.message);
} finally { } finally {
this.downloading = false; this.downloading = false;
this.setProgress({ active: false });
} }
} }
+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); > und `ui_tap`/`ui_text`/`ui_swipe`/`ui_key`/`app_launch` (steuern);
> `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern > `release_agent.sh` vorhanden. Damit läuft der volle Ablauf sehen→steuern
> (z.B. E-Mail-Konto einrichten). Nächstes: Feinschliff (M4). > (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.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" /> <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. --> <!-- app_launch: Start-Intents/App-Labels sind ab Android 11 sonst unsichtbar. -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" /> <uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
<!-- Overlay: ARIA blendet Text/Bild UEBER das laufende Programm ein (Fire TV). <!-- Overlay: ARIA blendet Text/Bild UEBER das laufende Programm ein (Fire TV).
@@ -60,6 +62,17 @@
android:taskAffinity="" android:taskAffinity=""
android:theme="@android:style/Theme.Translucent.NoTitleBar" /> 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 <service
android:name=".AgentService" android:name=".AgentService"
android:exported="false" android:exported="false"
@@ -32,6 +32,11 @@ class AgentService : Service() {
@Volatile var status: String = "gestoppt" @Volatile var status: String = "gestoppt"
@Volatile var connected: Boolean = false @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) { fun start(ctx: Context) {
val i = Intent(ctx, AgentService::class.java) val i = Intent(ctx, AgentService::class.java)
if (Build.VERSION.SDK_INT >= 26) ctx.startForegroundService(i) else ctx.startService(i) 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() { 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 host: EditText
private lateinit var port: EditText private lateinit var port: EditText
private lateinit var token: EditText private lateinit var token: EditText
@@ -78,6 +83,10 @@ class MainActivity : AppCompatActivity() {
tls = findViewById(R.id.tls) tls = findViewById(R.id.tls)
control = findViewById(R.id.control) control = findViewById(R.id.control)
statusView = findViewById(R.id.status) 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 { findViewById<Button>(R.id.btnScan).setOnClickListener {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
@@ -209,13 +218,19 @@ class MainActivity : AppCompatActivity() {
private fun refreshStatus() { private fun refreshStatus() {
val dot = if (AgentService.connected) "🟢" else "🔴" 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?) { override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent) super.onNewIntent(intent)
if (intent == null) return if (intent == null) return
setIntent(intent) setIntent(intent)
if (intent.action == ACTION_INSTALL_UPDATE) { AgentUpdater.install(this); return }
applyProvisioning(intent) applyProvisioning(intent)
} }
@@ -87,6 +87,7 @@ class RvsClient(
override fun onOpen(webSocket: WebSocket, response: Response) { override fun onOpen(webSocket: WebSocket, response: Response) {
onStatus(true, "verbunden") onStatus(true, "verbunden")
sendHello(webSocket) sendHello(webSocket)
sendUpdateCheck(webSocket)
startPing(webSocket) startPing(webSocket)
} }
override fun onMessage(webSocket: WebSocket, text: String) { override fun onMessage(webSocket: WebSocket, text: String) {
@@ -141,9 +142,17 @@ class RvsClient(
localIp()?.let { p.put("ip", it) } localIp()?.let { p.put("ip", it) }
p.put("caps", JSONArray(caps)) p.put("caps", JSONArray(caps))
p.put("control", config.controlEnabled) p.put("control", config.controlEnabled)
p.put("version", AgentUpdater.installedVersion(appCtx))
send(webSocket, "host_hello", p) 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). */ /** Erste echte IPv4 dieses Geraets im LAN (ohne Loopback). */
private fun localIp(): String? = try { private fun localIp(): String? = try {
java.net.NetworkInterface.getNetworkInterfaces().toList() java.net.NetworkInterface.getNetworkInterfaces().toList()
@@ -153,6 +162,8 @@ class RvsClient(
?.hostAddress ?.hostAddress
} catch (_: Throwable) { null } } catch (_: Throwable) { null }
private var pingCount = 0
private fun startPing(webSocket: WebSocket) { private fun startPing(webSocket: WebSocket) {
pingThread?.interrupt() pingThread?.interrupt()
pingThread = Thread { pingThread = Thread {
@@ -161,6 +172,8 @@ class RvsClient(
val p = JSONObject().put("hostId", config.hostId()) val p = JSONObject().put("hostId", config.hostId())
send(webSocket, "host_ping", p) send(webSocket, "host_ping", p)
sendHello(webSocket) // Re-announce (RVS replayt hellos nicht) 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() } }.also { it.start() }
} }
@@ -171,6 +184,11 @@ class RvsClient(
// Sprachdialog: chat/audio_pcm/stt_* laufen ueber dieselbe Verbindung. // Sprachdialog: chat/audio_pcm/stt_* laufen ueber dieselbe Verbindung.
// (Der RVS broadcastet alles im Raum — VoiceSession filtert auf den // (Der RVS broadcastet alles im Raum — VoiceSession filtert auf den
// eigenen Turn, damit nicht auch Handy und Stick gleichzeitig antworten.) // 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 != "host_command") {
if (type in VOICE_TYPES) { if (type in VOICE_TYPES) {
try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) } try { voice.handle(type, msg.optJSONObject("payload") ?: JSONObject()) }
@@ -25,6 +25,15 @@
android:text="Status: —" android:text="Status: —"
android:paddingBottom="16dp" /> 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 <Button
android:id="@+id/btnScan" android:id="@+id/btnScan"
android:layout_width="match_parent" 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 fi
done 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 ""
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}" echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}" echo -e "${GREEN}║ Host-Agent $TAG ist live!${NC}"
+86 -6
View File
@@ -11,6 +11,9 @@ const path = require("path");
const PORT = parseInt(process.env.PORT || "3000", 10); const PORT = parseInt(process.env.PORT || "3000", 10);
const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10); const MAX_SESSIONS = parseInt(process.env.MAX_SESSIONS || "10", 10);
const UPDATES_DIR = process.env.UPDATES_DIR || "/updates"; 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. // HTTPS statt HTTP auf PORT — fuer die Strecke externer Reverse-Proxy → RVS.
// Das oeffentliche Zertifikat hat der Proxy; hier reicht ein selbstsigniertes // Das oeffentliche Zertifikat hat der Proxy; hier reicht ein selbstsigniertes
// (wird beim ersten Start erzeugt). NICHT zusammen mit dem Caddy im Stack // (wird beim ersten Start erzeugt). NICHT zusammen mit dem Caddy im Stack
@@ -24,7 +27,7 @@ const ALLOWED_TYPES = new Set([
"chat", "audio", "file", "location", "mode", "log", "event", "heartbeat", "chat", "audio", "file", "location", "mode", "log", "event", "heartbeat",
"file_request", "file_response", "file_saved", "stt_result", "config", "tts_request", "file_request", "file_response", "file_saved", "stt_result", "config", "tts_request",
"xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved", "xtts_request", "xtts_response", "xtts_list_voices", "xtts_voices_list", "voice_upload", "xtts_voice_saved",
"update_check", "update_available", "update_download", "update_data", "update_check", "update_available", "update_none", "update_download", "update_data",
"agent_activity", "cancel_request", "interject", "agent_activity", "cancel_request", "interject",
"audio_pcm", "audio_pcm",
"file_from_aria", "file_from_aria",
@@ -148,6 +151,8 @@ const ALLOWED_TYPES = new Set([
// Wer ist im Raum? client_hello: Client stellt sich vor (bleibt beim RVS); // Wer ist im Raum? client_hello: Client stellt sich vor (bleibt beim RVS);
// room_clients_query → room_clients: Liste NUR des eigenen Raums. // room_clients_query → room_clients: Liste NUR des eigenen Raums.
"client_hello", "room_clients_query", "room_clients", "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", "node_stats_stream_start", "node_stats_stream_stop",
"node_stats_history_request", "node_stats_history", "node_stats_history_request", "node_stats_history",
"node_stats_reset", "node_stats_reset_done", "node_stats_reset", "node_stats_reset_done",
@@ -403,6 +408,23 @@ function handleHttpRequest(req, res) {
return; return;
} }
// 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 isAgent = pathname.startsWith("/update/agent/");
serveApk(req, res, isAgent ? getLatestAPK(AGENT_UPDATES_DIR) : getLatestAPK(),
isAgent ? "aria-host-agent" : "ARIA");
return;
}
// Health-Endpoint // Health-Endpoint
if (req.method === "GET" && pathname === "/health") { if (req.method === "GET" && pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" }); res.writeHead(200, { "Content-Type": "application/json" });
@@ -592,6 +614,29 @@ function registerClient(ws, token) {
}, },
timestamp: Date.now(), timestamp: Date.now(),
})); }));
} else {
// Auch "nichts Neues" beantworten — die App zeigt dann, dass der
// Update-Server erreichbar ist und welche Version er hat
ws.send(JSON.stringify({
type: "update_none",
payload: { latest: apkInfo ? apkInfo.version : "", current: clientVersion },
timestamp: Date.now(),
}));
}
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; return;
} }
@@ -736,15 +781,50 @@ wss.on("close", () => {
let latestVersion = null; 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 { try {
if (!fs.existsSync(UPDATES_DIR)) return null; if (!fs.existsSync(dir)) return null;
const files = fs.readdirSync(UPDATES_DIR) const files = fs.readdirSync(dir)
.filter(f => f.endsWith(".apk")) .filter(f => f.endsWith(".apk"))
.map(f => { .map(f => {
// ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk // ARIA-v0.0.2.3.apk oder ARIA-Cockpit-release.apk
const match = f.match(/(\d+\.\d+\.\d+[\.\d]*)/); 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) .filter(f => f.version)
.sort((a, b) => compareVersions(b.version, a.version)); // Neueste zuerst .sort((a, b) => compareVersions(b.version, a.version)); // Neueste zuerst