diff --git a/android/src/workspace/assets/novncHtml.ts b/android/src/workspace/assets/novncHtml.ts new file mode 100644 index 0000000..3524510 --- /dev/null +++ b/android/src/workspace/assets/novncHtml.ts @@ -0,0 +1,97 @@ +/** + * novncHtml — noVNC-Client fuer die WebView, dessen WebSocket durch den + * RVS-Tunnel gebrueckt wird. + * + * Trick: window.WebSocket wird VOR dem Laden von noVNC durch einen Shim + * ersetzt. noVNC (RFB) glaubt, ein echtes WebSocket zu benutzen; tatsaechlich + * gehen die RFB-Bytes als Base64 per postMessage an RN → RVS → Bridge → QEMU + * (und zurueck). Da RFB "server-speaks-first" ist, ist die Reihenfolge robust. + * + * noVNC wird vom CDN geladen (das Telefon hat Internet, da es ohnehin am RVS + * haengt). Voll-offline-Bundling waere ein spaeterer Schritt. + * + * Protokoll: + * RN -> WebView window.ariaVnc.onData(b64) RFB-Bytes vom Server + * WebView -> RN {event:'ready'} RFB initialisiert → Tunnel oeffnen + * {event:'vnc_send', b64} RFB-Bytes an den Server + * {event:'vnc_close'} RFB hat geschlossen + * {event:'vnc_state', state} connected|disconnected + */ + +export const NOVNC_HTML = ` + + +
+
Verbinde mit Desktop …
+`; diff --git a/android/src/workspace/tiles/VncTile.tsx b/android/src/workspace/tiles/VncTile.tsx index b588e45..19d8124 100644 --- a/android/src/workspace/tiles/VncTile.tsx +++ b/android/src/workspace/tiles/VncTile.tsx @@ -1,31 +1,105 @@ /** * VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS). - * Platzhalter fuer Commit 4; der noVNC-Tunnel folgt in Commit 7. + * + * Nur im Fokus aktiv: dann wird die WebView gemountet, bei 'ready' der + * RVS-VNC-Tunnel geoeffnet (desktop.openVnc). Server-Bytes (vnc_data) werden in + * die WebView injiziert, RFB-Bytes der WebView (vnc_send) gehen als vnc_input + * zurueck. Verlaesst man die Kachel, wird der Tunnel geschlossen (die VM laeuft + * auf dem Host weiter). */ -import React from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; +import { WebView, WebViewMessageEvent } from 'react-native-webview'; +import desktop from '../../services/desktop'; +import { NOVNC_HTML } from '../assets/novncHtml'; interface Props { projectId: string; focused: boolean; } -const VncTile: React.FC = () => { +const VncTile: React.FC = ({ projectId, focused }) => { + const webRef = useRef(null); + const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle'); + const unsubDataRef = useRef void)>(null); + + // Aufraeumen: Tunnel zu + Daten-Abo weg. + const teardown = useCallback(() => { + if (unsubDataRef.current) { unsubDataRef.current(); unsubDataRef.current = null; } + desktop.closeVnc(); + }, []); + + // Fokus verloren / Unmount → Tunnel schliessen. + useEffect(() => { + if (!focused) { teardown(); setStatus('idle'); } + return () => teardown(); + }, [focused, teardown]); + + const onMessage = useCallback((e: WebViewMessageEvent) => { + let m: any; + try { m = JSON.parse(e.nativeEvent.data); } catch { return; } + if (m.event === 'ready') { + // WebView + RFB bereit → Tunnel oeffnen und Server-Bytes einspeisen. + setStatus('connecting'); + unsubDataRef.current = desktop.onVncData((b64) => { + const js = `window.ariaVnc && window.ariaVnc.onData(${JSON.stringify(b64)}); true;`; + webRef.current?.injectJavaScript(js); + }); + desktop.openVnc(projectId); + } else if (m.event === 'vnc_send') { + desktop.sendInput(m.b64); + } else if (m.event === 'vnc_close') { + desktop.closeVnc(); + } else if (m.event === 'vnc_state') { + if (m.state === 'connected') setStatus('connected'); + else if (m.state === 'disconnected') setStatus('disconnected'); + } + }, [projectId]); + + if (!focused) { + return ( + + 🖥️ + Desktop + Antippen zum Verbinden + + ); + } + return ( - 🖥️ - Desktop - Kein Desktop verbunden + + {status !== 'connected' && ( + + + {status === 'connecting' ? 'Verbinde …' : status === 'disconnected' ? 'Getrennt' : ''} + + + )} ); }; const styles = StyleSheet.create({ - container: { flex: 1, backgroundColor: '#000000', alignItems: 'center', justifyContent: 'center' }, + container: { flex: 1, backgroundColor: '#000000' }, + web: { flex: 1, backgroundColor: '#000000' }, + placeholder: { flex: 1, backgroundColor: '#000000', alignItems: 'center', justifyContent: 'center' }, icon: { fontSize: 64, marginBottom: 16 }, text: { color: '#FFFFFF', fontSize: 18, fontWeight: '700' }, sub: { color: '#9090B0', fontSize: 14, marginTop: 8 }, + overlay: { position: 'absolute', top: 10, left: 0, right: 0, alignItems: 'center' }, + overlayText: { color: '#9090B0', fontSize: 12, backgroundColor: 'rgba(0,0,0,0.6)', paddingHorizontal: 10, paddingVertical: 4, borderRadius: 10, overflow: 'hidden' }, }); export default VncTile; diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index 22bc3fc..de24f61 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -734,6 +734,11 @@ class ARIABridge: # Beeinflusst das Timeout fuer stt_request — bei "loading" warten wir laenger, # weil das Modell beim ersten Request noch ~1-2 Min runtergeladen werden kann. self._remote_stt_ready: bool = False + # VNC-Tunnel (Workspace-Desktop): session → {"writer": StreamWriter, + # "task": asyncio.Task}. Wir bruecken rohes RFB-TCP (QEMU-VNC auf dem + # Host) <-> RVS (vnc_data/vnc_input, Base64-in-JSON). + self._vnc_sessions: dict[str, dict] = {} + self._vnc_host: str = os.environ.get("ARIA_VNC_HOST", "host.docker.internal") # FLUX-Render-Requests die aktuell auf Antwort der flux-bridge (Gamebox) warten. # requestId → Future mit dem flux_response-Payload (oder None bei Fehler). self._pending_flux: dict[str, asyncio.Future] = {} @@ -3364,6 +3369,43 @@ class ARIABridge: pid or "main", rel, len(full_text), "ok" if ok else "abgelehnt") return + elif msg_type == "check_desktop": + session = str(payload.get("session") or "main") + try: + port = int(payload.get("port") or 5901) + except (TypeError, ValueError): + port = 5901 + asyncio.create_task(self._check_desktop(session, port)) + return + + elif msg_type == "vnc_open": + session = str(payload.get("session") or "main") + try: + port = int(payload.get("port") or 5901) + except (TypeError, ValueError): + port = 5901 + asyncio.create_task(self._vnc_open(session, port)) + return + + elif msg_type == "vnc_close": + session = str(payload.get("session") or "main") + asyncio.create_task(self._vnc_close(session)) + return + + elif msg_type == "vnc_input": + session = str(payload.get("session") or "main") + b64 = payload.get("b64") or "" + sess = self._vnc_sessions.get(session) + if sess and b64: + try: + data = base64.b64decode(b64) + sess["writer"].write(data) + # drain nicht awaiten (wir sind synchron im Dispatcher) — + # write puffert, der Kernel-Socket schluckt RFB-Input locker. + except Exception as exc: + logger.warning("[vnc] input schreiben (%s) fehlgeschlagen: %s", session, exc) + return + elif msg_type == "config_request": # Eine andere Bridge (whisper/f5tts) bittet um die aktuelle Voice- # Config — passiert wenn sie sich connected, weil sie sonst die @@ -4363,6 +4405,94 @@ class ARIABridge: logger.warning("[code_file] Schreiben fehlgeschlagen %s/%s: %s", project_id, rel_path, exc) return False + async def _check_desktop(self, session: str, port: int) -> None: + """Probt ob auf dem Host eine QEMU-VNC laeuft und meldet desktop_status.""" + available = False + try: + fut = asyncio.open_connection(self._vnc_host, port) + reader, writer = await asyncio.wait_for(fut, timeout=2.0) + available = True + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + except Exception: + available = False + await self._send_to_rvs({ + "type": "desktop_status", + "payload": { + "available": available, + "session": session, + "message": (f"Desktop laeuft (VNC {self._vnc_host}:{port})" if available + else "Kein Desktop verbunden"), + }, + "timestamp": int(time.time() * 1000), + }) + + async def _vnc_open(self, session: str, port: int) -> None: + """Oeffnet die TCP-Verbindung zur QEMU-VNC und streamt RFB-Bytes als + vnc_data ueber RVS. Bestehende Session wird vorher geschlossen.""" + await self._vnc_close(session) + try: + reader, writer = await asyncio.open_connection(self._vnc_host, port) + except Exception as exc: + logger.warning("[vnc] open %s:%d fehlgeschlagen: %s", self._vnc_host, port, exc) + await self._send_to_rvs({ + "type": "desktop_status", + "payload": {"available": False, "session": session, + "message": f"VNC-Verbindung fehlgeschlagen: {exc}"}, + "timestamp": int(time.time() * 1000), + }) + return + task = asyncio.create_task(self._vnc_reader_loop(session, reader)) + self._vnc_sessions[session] = {"writer": writer, "task": task} + logger.info("[vnc] Tunnel offen: session=%s → %s:%d", session, self._vnc_host, port) + + async def _vnc_reader_loop(self, session: str, reader: asyncio.StreamReader) -> None: + try: + while True: + data = await reader.read(16384) + if not data: + break + await self._send_to_rvs({ + "type": "vnc_data", + "payload": {"session": session, "b64": base64.b64encode(data).decode("ascii")}, + "timestamp": int(time.time() * 1000), + }) + except asyncio.CancelledError: + raise + except Exception as exc: + logger.info("[vnc] reader-loop (%s) beendet: %s", session, exc) + finally: + # TCP-Ende → App informieren (Desktop weg). + if session in self._vnc_sessions: + await self._send_to_rvs({ + "type": "desktop_status", + "payload": {"available": False, "session": session, "message": "VNC-Verbindung beendet"}, + "timestamp": int(time.time() * 1000), + }) + + async def _vnc_close(self, session: str) -> None: + sess = self._vnc_sessions.pop(session, None) + if not sess: + return + task = sess.get("task") + if task: + task.cancel() + try: + await task + except (asyncio.CancelledError, Exception): + pass + writer = sess.get("writer") + if writer: + try: + writer.close() + await writer.wait_closed() + except Exception: + pass + logger.info("[vnc] Tunnel geschlossen: session=%s", session) + async def _delete_chat_message(self, ts: int) -> dict: """Entfernt eine Bubble: aus chat_backup.jsonl + Brain conversation, broadcastet chat_message_deleted via RVS.