From 21a315ca71c692544f144ed67233f6316ba1523e Mon Sep 17 00:00:00 2001 From: duffyduck Date: Thu, 14 May 2026 15:42:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(debug):=20App-Crash-Reporting=20via=20RVS?= =?UTF-8?q?=20=E2=80=94=20Logs=20in=20der=20Diagnostic-UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stefan ist unterwegs, ADB-Zugriff nicht moeglich. Loesung: die App loggt ihre eigenen Crashes via RVS, Bridge sammelt sie in /shared/logs/app.log, Diagnostic-Server liefert sie als JSON. Damit braucht's keinen ADB mehr — Crashes sind sofort vom Browser (oder Claude per curl) lesbar. Komponenten: 1. App components/ErrorBoundary.tsx - React-ErrorBoundary fuer kritische Sections - componentDidCatch → reportAppError (RVS-Send) - UI zeigt Error-Box statt White-Screen + Reset-Button 2. App services/logger.ts - reportAppError(scope, message, stack) → rvs.send('app_log', ...) - installGlobalCrashReporter() haengt sich an ErrorUtils.setGlobalHandler UND HermesInternal.enablePromiseRejectionTracker — fangt sowohl ungefangene Errors als auch unhandled Promise-Rejections - Konsole bleibt parallel aktiv (damit ADB im Dev-Build weiter was sieht) 3. App App.tsx: installGlobalCrashReporter() im useEffect zusammen mit initLogger. 4. App ChatScreen.tsx: - Inbox-Modal mit ErrorBoundary umschlossen (scope: InboxModal, onReset schliesst Modal) - MemoryDetailModal mit ErrorBoundary umschlossen - DetailModal wird nur noch konditional gerendert (memoryDetailId != null) statt immer visible-toggle — vermeidet potentielles Modal-Stacking-Problem 5. RVS server.js: ALLOWED_TYPES += "app_log" 6. Bridge aria_bridge.py: - elif msg_type == "app_log": haengt eine Zeile an /shared/logs/app.log (JSONL, jedes Item {ts, platform, level, scope, message, stack}) - Plus log.info Hinweis fuer das normale Bridge-Log 7. Diagnostic server.js: - GET /api/app-log[?limit=N] → letzte N Eintraege als JSON - POST /api/app-log/clear → log-Datei loeschen Workflow zum Debuggen des Inbox-Crashes: Stefan rebuilded App → drueckt Inbox → ErrorBoundary fangt den Crash (oder Global-Handler bei ungefangenem Error) → reportAppError → RVS → Bridge schreibt nach /shared/logs/app.log → Stefan oder Claude rufen GET /api/app-log auf → sehen Stacktrace. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/App.tsx | 5 +- android/src/components/ErrorBoundary.tsx | 89 ++++++++++++++++++++++++ android/src/screens/ChatScreen.tsx | 19 +++-- android/src/services/logger.ts | 76 ++++++++++++++++++++ bridge/aria_bridge.py | 23 ++++++ diagnostic/server.js | 36 ++++++++++ rvs/server.js | 1 + 7 files changed, 242 insertions(+), 7 deletions(-) create mode 100644 android/src/components/ErrorBoundary.tsx diff --git a/android/App.tsx b/android/App.tsx index e82cafe..36a3069 100644 --- a/android/App.tsx +++ b/android/App.tsx @@ -13,7 +13,7 @@ import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import ChatScreen from './src/screens/ChatScreen'; import SettingsScreen from './src/screens/SettingsScreen'; import rvs from './src/services/rvs'; -import { initLogger } from './src/services/logger'; +import { initLogger, installGlobalCrashReporter } from './src/services/logger'; // --- Navigation --- @@ -49,6 +49,9 @@ const App: React.FC = () => { // initLogger ist async aber blockt nichts — solange er noch laueft, // loggen wir normal (Default an), danach respektiert console.log das Setting. initLogger().catch(() => {}); + // Crash-Reporter installieren — ungefangene JS-Errors landen via RVS + // bei der Bridge (sichtbar in /shared/logs/app.log + Diagnostic-API) + installGlobalCrashReporter(); const initConnection = async () => { const config = await rvs.loadConfig(); if (config) { diff --git a/android/src/components/ErrorBoundary.tsx b/android/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..17e95a1 --- /dev/null +++ b/android/src/components/ErrorBoundary.tsx @@ -0,0 +1,89 @@ +/** + * ErrorBoundary — fängt React-Render-Fehler und zeigt eine Error-Box + * statt White-Screen-of-Death. Plus: Crash wird zum logger geschickt, + * der das ueber RVS an die Bridge weiterleitet. + * + * Einsatz: kritische Komponenten/Modals damit ein Bug nicht die ganze + * App killt. + */ + +import React from 'react'; +import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import { reportAppError } from '../services/logger'; + +interface Props { + children: React.ReactNode; + /** Optional: Bezeichnung der eingegrenzten Section fuer's Log. */ + scope?: string; + /** Optional: Reset-Callback (z.B. Modal schliessen) — Button ist dann sichtbar. */ + onReset?: () => void; +} + +interface State { + err: Error | null; + info: string; +} + +export class ErrorBoundary extends React.Component { + constructor(props: Props) { + super(props); + this.state = { err: null, info: '' }; + } + + static getDerivedStateFromError(err: Error): Partial { + return { err }; + } + + componentDidCatch(err: Error, info: any) { + const stack = info?.componentStack || ''; + this.setState({ info: stack }); + reportAppError({ + scope: this.props.scope || 'ErrorBoundary', + message: err?.message || String(err), + stack: (err?.stack || '') + '\n--- componentStack ---\n' + stack, + }); + } + + render() { + if (this.state.err) { + return ( + + ⚠️ Etwas ist schiefgegangen + {this.props.scope || 'unbekannte Komponente'} + + {this.state.err.message || String(this.state.err)} + {this.state.info ? {this.state.info} : null} + + {this.props.onReset ? ( + { this.setState({err:null,info:''}); this.props.onReset?.(); }}> + Schliessen + zurueck + + ) : ( + this.setState({err:null,info:''})}> + Erneut versuchen + + )} + + Crash wurde an die Bridge gemeldet — sichtbar in der Diagnostic-Web-UI unter /api/app-log + + + ); + } + return this.props.children; + } +} + +const s = StyleSheet.create({ + box: { flex:1, padding:16, backgroundColor:'#1A0A0A' }, + title: { color:'#FF6B6B', fontWeight:'bold', fontSize:16, marginBottom:6 }, + scope: { color:'#FF9500', fontSize:12, marginBottom:10 }, + scroll: { flex:1, backgroundColor:'#0D0D1A', borderRadius:6, padding:10, marginBottom:10 }, + msg: { color:'#FF6B6B', fontSize:13, marginBottom:8 }, + stack: { color:'#8888AA', fontSize:11, fontFamily:'monospace' }, + btn: { backgroundColor:'#0096FF', paddingVertical:10, borderRadius:6, alignItems:'center' }, + btnText: { color:'#fff', fontWeight:'600' }, + hint: { color:'#555570', fontSize:10, marginTop:8, textAlign:'center' }, +}); + +export default ErrorBoundary; diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index b72ff76..c5a1ff0 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -30,6 +30,7 @@ import { Dimensions } from 'react-native'; import ZoomableImage from '../components/ZoomableImage'; import MemoryDetailModal from '../components/MemoryDetailModal'; import MemoryBrowser from '../components/MemoryBrowser'; +import ErrorBoundary from '../components/ErrorBoundary'; import rvs, { RVSMessage, ConnectionState } from '../services/rvs'; import audioService from '../services/audio'; import wakeWordService from '../services/wakeword'; @@ -1839,17 +1840,22 @@ const ChatScreen: React.FC = () => { {/* Memory-Detail/Edit-Modal — wird durch Tap auf eine memorySaved-Bubble geoeffnet */} - setMemoryDetailId(null)} - onDeleted={() => setMemoryDetailId(null)} - /> + {memoryDetailId ? ( + setMemoryDetailId(null)}> + setMemoryDetailId(null)} + onDeleted={() => setMemoryDetailId(null)} + /> + + ) : null} {/* Notizen-Inbox — Listet alle Memories aus dem aktuellen Chat (Special-Bubbles). Bestes-Aus-beiden-Welten: nur die Memory-IDs aus den memorySaved-Bubbles des aktuellen Chats, plus den vollen Browser darunter wenn der User mehr will. */} setInboxVisible(false)}> + setInboxVisible(false)}> {'🗂️'} Notizen-Inbox @@ -1935,6 +1941,7 @@ const ChatScreen: React.FC = () => { { setInboxVisible(false); setMemoryDetailId(id); }} /> + {/* Bild-Vollbild Modal */} diff --git a/android/src/services/logger.ts b/android/src/services/logger.ts index 4610ee2..f7de265 100644 --- a/android/src/services/logger.ts +++ b/android/src/services/logger.ts @@ -7,6 +7,8 @@ */ import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Platform } from 'react-native'; +import rvs from './rvs'; export const VERBOSE_LOGGING_KEY = 'aria_verbose_logging'; @@ -39,3 +41,77 @@ export function setVerboseLogging(verbose: boolean): void { applyState(); AsyncStorage.setItem(VERBOSE_LOGGING_KEY, String(verbose)).catch(() => {}); } + +// ─── App-Crash-Reporting via RVS ──────────────────────────────────── +// +// Wenn die App crasht — egal ob React-Render-Fehler (ErrorBoundary) oder +// ungefangener JS-Error (ErrorUtils-Handler) — schicken wir den Crash +// als RVS-Message vom Typ "app_log" an die Bridge. Die schreibt in +// /shared/logs/app.log, sodass wir/Diagnostic die Crashes mitlesen +// koennen ohne ADB. + +interface AppErrorEvent { + scope: string; + message: string; + stack?: string; + level?: 'error' | 'warn' | 'info'; +} + +let _reportingInstalled = false; + +/** Schickt einen App-Fehler via RVS an die Bridge. */ +export function reportAppError(ev: AppErrorEvent): void { + try { + rvs.send('app_log' as any, { + ts: Date.now(), + platform: Platform.OS, + level: ev.level || 'error', + scope: ev.scope, + message: ev.message, + stack: (ev.stack || '').slice(0, 8000), + }); + } catch { + // RVS noch nicht connected — Fehler geht im console weiter. + } + // Plus lokal: console.error, damit Stefan's adb (wenn doch mal verfuegbar) + // den Crash sieht. + console.error(`[app-error scope=${ev.scope}]`, ev.message, '\n', ev.stack || ''); +} + +/** Installiert einen globalen JS-Error-Handler der ungefangene Errors via + * RVS an die Bridge schickt. Beim App-Start aufrufen. */ +export function installGlobalCrashReporter(): void { + if (_reportingInstalled) return; + _reportingInstalled = true; + try { + const g: any = global as any; + const prev = g.ErrorUtils?.getGlobalHandler?.(); + g.ErrorUtils?.setGlobalHandler?.((err: any, isFatal: boolean) => { + reportAppError({ + scope: isFatal ? 'global-fatal' : 'global-nonfatal', + message: (err && err.message) || String(err), + stack: err && err.stack, + }); + // Original-Handler weiterhin aufrufen damit React-Native das System- + // Crash-Overlay zeigt (im Dev-Build) bzw. in Production sauber stirbt. + if (typeof prev === 'function') { + try { prev(err, isFatal); } catch {} + } + }); + // unhandled Promise-Rejections — manche RN-Versionen haben das nicht + // automatisch im ErrorUtils. + g.HermesInternal?.enablePromiseRejectionTracker?.({ + allRejections: true, + onUnhandled: (id: number, err: any) => { + reportAppError({ + scope: 'promise-unhandled', + level: 'warn', + message: (err && err.message) || String(err), + stack: err && err.stack, + }); + }, + }); + } catch { + // ErrorUtils nicht da → nix machen + } +} diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 5814920..90cabcd 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -1824,6 +1824,29 @@ class ARIABridge: logger.warning("[rvs] delete_message fehlgeschlagen: %s", result.get("error")) return + elif msg_type == "app_log": + # App schickt Crash/Error/Info-Log via RVS — wir schreiben das + # in /shared/logs/app.log (JSONL) damit Diagnostic + Claude + # mitlesen koennen, auch ohne ADB-Zugriff aufs Handy. + try: + log_dir = Path("/shared/logs") + log_dir.mkdir(parents=True, exist_ok=True) + line = { + "ts": payload.get("ts") or int(time.time() * 1000), + "platform": payload.get("platform", "?"), + "level": payload.get("level", "info"), + "scope": payload.get("scope", ""), + "message": payload.get("message", ""), + "stack": payload.get("stack", ""), + } + with (log_dir / "app.log").open("a", encoding="utf-8") as f: + f.write(json.dumps(line, ensure_ascii=False) + "\n") + logger.info("[app-log] %s %s: %s", + line["level"], line["scope"], line["message"][:120]) + except Exception as exc: + logger.warning("[app-log] schreiben fehlgeschlagen: %s", exc) + return + elif msg_type == "brain_request": # Generischer RVS-Proxy fuer die Brain-HTTP-API. # payload: {requestId, method, path, body?, bodyBase64?, contentType?} diff --git a/diagnostic/server.js b/diagnostic/server.js index 11beae0..db7471d 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -1338,6 +1338,42 @@ const server = http.createServer((req, res) => { else broadcast({ type: "agent_activity", activity: "idle" }); res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true })); + } else if (req.url.startsWith("/api/app-log") && req.method === "GET") { + // App-Crash-Reporting-Log lesen — die App schickt JS-Errors via RVS, + // Bridge schreibt JSONL nach /shared/logs/app.log. Wir liefern die + // letzten 200 Eintraege (oder ?limit=N). + const url = new URL(req.url, "http://x"); + const limit = Math.max(1, Math.min(2000, parseInt(url.searchParams.get("limit") || "200", 10) || 200)); + try { + const file = "/shared/logs/app.log"; + if (!fs.existsSync(file)) { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ count: 0, entries: [] })); + return; + } + const raw = fs.readFileSync(file, "utf-8"); + const lines = raw.split("\n").filter(l => l.trim()); + const tail = lines.slice(-limit); + const entries = tail.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ count: entries.length, entries })); + } catch (err) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: err.message })); + } + return; + } else if (req.url === "/api/app-log/clear" && req.method === "POST") { + // App-Log leeren — nach erfolgreichem Debug. + try { + const file = "/shared/logs/app.log"; + if (fs.existsSync(file)) fs.unlinkSync(file); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: true })); + } catch (err) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: err.message })); + } + return; } else if (req.url === "/api/files-list" && req.method === "GET") { // Liste alle Dateien in /shared/uploads/ — die kommen entweder vom User // (Upload aus App/Diagnostic) oder von ARIA (aria_. Pattern). diff --git a/rvs/server.js b/rvs/server.js index 42254a9..e001c3c 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -31,6 +31,7 @@ const ALLOWED_TYPES = new Set([ "chat_history_request", "chat_history_response", "chat_cleared", "delete_message_request", "chat_message_deleted", "brain_request", "brain_response", + "app_log", "file_delete_batch_request", "file_delete_batch_response", "file_zip_request", "file_zip_response", "xtts_delete_voice",