diff --git a/README.md b/README.md index f390350..63c07b4 100644 --- a/README.md +++ b/README.md @@ -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.) - OAuth-Callback HTTP: `GET /oauth/callback/{service}?code=...` → broadcastet als `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 **Auto-Update APK bereitstellen:** diff --git a/android/App.tsx b/android/App.tsx index 956f2d4..c2e13c7 100644 --- a/android/App.tsx +++ b/android/App.tsx @@ -16,6 +16,7 @@ import WorkspaceScreen from './src/workspace/WorkspaceScreen'; import SettingsScreen from './src/screens/SettingsScreen'; import ViewModeToggle from './src/components/ViewModeToggle'; import SecurityLock from './src/components/SecurityLock'; +import UpdateProgress from './src/components/UpdateProgress'; import rvs from './src/services/rvs'; import { initLogger, installGlobalCrashReporter } from './src/services/logger'; import { acquireBackgroundAudio } from './src/services/backgroundAudio'; @@ -193,6 +194,7 @@ const App: React.FC = () => { /> + ); }; diff --git a/android/src/components/UpdateProgress.tsx b/android/src/components/UpdateProgress.tsx new file mode 100644 index 0000000..c0aa0ee --- /dev/null +++ b/android/src/components/UpdateProgress.tsx @@ -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(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 ( + {}}> + + + Update auf Version {p.version} + {p.status} + + + + + {known ? `${pct} % · ` : ''} + {p.receivedMB > 0 ? `${p.receivedMB.toFixed(1)} von ${p.totalMB.toFixed(1)} MB` : `${p.totalMB.toFixed(1)} MB`} + + {p.status !== 'Installiere …' && ( + updateService.cancelDownload()}> + Abbrechen + + )} + + + + ); +}; + +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; diff --git a/android/src/services/updater.ts b/android/src/services/updater.ts index 380427f..2ed1a9c 100644 --- a/android/src/services/updater.ts +++ b/android/src/services/updater.ts @@ -17,6 +17,26 @@ const APP_VERSION = packageJson.version || '0.0.0.0'; 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 { version: string; downloadUrl: string; @@ -27,6 +47,10 @@ class UpdateService { private listeners: UpdateCallback[] = []; private checking = 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; + private cancelled = false; constructor() { // Beim Start alte APK-Reste aus dem Cache wegraeumen — wenn diese App @@ -150,46 +174,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): 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 { + 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(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 { + this.setProgress({ status: 'Lade herunter (ohne Fortschritt) …', fraction: -1 }); + rvs.send('update_download' as any, {}); + const base64 = await new Promise((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 { if (this.downloading) return; 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 { - console.log(`[Update] Fordere APK v${info.version} an...`); - Alert.alert('Download gestartet', `Version ${info.version} wird ueber RVS heruntergeladen...`); - - // 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 + console.log(`[Update] Lade APK v${info.version} …`); + // Vor dem Download alte APKs im Cache wegraeumen await this.cleanupOldApks(); - // Base64 als APK-Datei speichern - const destPath = `${RNFS.CachesDirectoryPath}/${apkData.fileName}`; - await RNFS.writeFile(destPath, apkData.base64, 'base64'); + const viaHttp = await this.downloadViaHttp(info, destPath); + if (!viaHttp) await this.downloadViaWebSocket(info, 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) if (Platform.OS === 'android') { @@ -199,15 +302,17 @@ class UpdateService { } catch (installErr: any) { Alert.alert( '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) { 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 { this.downloading = false; + this.setProgress({ active: false }); } } diff --git a/rvs/server.js b/rvs/server.js index 4fee627..0118451 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -403,6 +403,51 @@ 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") { + 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); + return; + } + // Health-Endpoint if (req.method === "GET" && pathname === "/health") { res.writeHead(200, { "Content-Type": "application/json" }); @@ -743,7 +788,7 @@ function getLatestAPK() { .filter(f => f.endsWith(".apk")) .map(f => { // 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 }; }) .filter(f => f.version)