feat: Workspace-Umbau Schritt 7 — VNC-Live-Desktop durch RVS getunnelt

QEMU-VNC erscheint live in der App-Desktop-Kachel, komplett ueber RVS:
- Bridge: TCP↔RVS-VNC-Bruecke. vnc_open oeffnet asyncio-Verbindung zu
  host.docker.internal:5901, Reader-Loop streamt RFB-Bytes als vnc_data;
  vnc_input schreibt zurueck; vnc_close raeumt auf. check_desktop probt den
  Port und meldet desktop_status. Base64-in-JSON, kein websockify/noVNC auf
  dem Host noetig.
- App: novncHtml.ts laedt noVNC (CDN) und ersetzt window.WebSocket durch einen
  Shim, der RFB-Bytes per postMessage ueber RVS brueckt (server-speaks-first →
  timing-robust). VncTile mountet die WebView nur im Fokus, oeffnet bei 'ready'
  den Tunnel (desktop.openVnc), speist Server-Bytes ein und schickt Eingaben
  als vnc_input; beim Verlassen wird der Tunnel geschlossen (VM laeuft weiter).

tsc/py clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 00:02:04 +02:00
co-authored by Claude Opus 4.8
parent a082c8398e
commit 0b35ea9bde
3 changed files with 308 additions and 7 deletions
+97
View File
@@ -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 = `<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<style>
* { margin:0; padding:0; }
html, body { height:100%; background:#000; overflow:hidden; }
#screen { width:100%; height:100%; }
#msg { position:absolute; top:8px; left:0; right:0; text-align:center;
color:#9090B0; font-family:sans-serif; font-size:12px; pointer-events:none; }
</style></head><body>
<div id="screen"></div>
<div id="msg">Verbinde mit Desktop …</div>
<script>
(function(){
function post(o){ if(window.ReactNativeWebView) window.ReactNativeWebView.postMessage(JSON.stringify(o)); }
function b64FromBytes(bytes){
var CHUNK=0x8000, parts=[];
for(var i=0;i<bytes.length;i+=CHUNK){ parts.push(String.fromCharCode.apply(null, bytes.subarray(i,i+CHUNK))); }
return btoa(parts.join(''));
}
function bytesFromB64(b64){
var s=atob(b64), a=new Uint8Array(s.length);
for(var i=0;i<s.length;i++) a[i]=s.charCodeAt(i);
return a;
}
// --- WebSocket-Shim ---
function BridgeSocket(url, protocols){
this.url=url; this.protocol=''; this.readyState=0; this.binaryType='arraybuffer';
this.onopen=null; this.onclose=null; this.onerror=null; this.onmessage=null;
var self=this; window.__vncSocket=self;
setTimeout(function(){ self.readyState=1; if(self.onopen) self.onopen({type:'open'}); }, 0);
}
BridgeSocket.CONNECTING=0; BridgeSocket.OPEN=1; BridgeSocket.CLOSING=2; BridgeSocket.CLOSED=3;
BridgeSocket.prototype.send=function(data){
var bytes;
if(data instanceof ArrayBuffer) bytes=new Uint8Array(data);
else if(ArrayBuffer.isView(data)) bytes=new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
else bytes=new Uint8Array(0);
post({event:'vnc_send', b64:b64FromBytes(bytes)});
};
BridgeSocket.prototype.close=function(){
if(this.readyState===3) return;
this.readyState=3; if(this.onclose) this.onclose({type:'close'}); post({event:'vnc_close'});
};
BridgeSocket.prototype.addEventListener=function(t,fn){ this['on'+t]=fn; };
BridgeSocket.prototype.removeEventListener=function(t){ this['on'+t]=null; };
window.WebSocket = BridgeSocket;
// Eingehende Server-Bytes → in den Shim einspeisen.
window.ariaVnc = {
onData:function(b64){
var sock=window.__vncSocket;
if(!sock || !sock.onmessage) return;
sock.onmessage({ type:'message', data: bytesFromB64(b64).buffer });
}
};
var msg=document.getElementById('msg');
// noVNC per ESM vom CDN laden.
import('https://cdn.jsdelivr.net/npm/@novnc/novnc@1.4.0/core/rfb.js').then(function(mod){
var RFB = mod.default;
var rfb = new RFB(document.getElementById('screen'), 'ws://aria-vnc/', {});
rfb.scaleViewport = true;
rfb.clipViewport = false;
rfb.addEventListener('connect', function(){ msg.style.display='none'; post({event:'vnc_state', state:'connected'}); });
rfb.addEventListener('disconnect', function(e){
msg.style.display='block'; msg.textContent='Desktop getrennt';
post({event:'vnc_state', state:'disconnected'});
});
window.__rfb = rfb;
post({event:'ready'});
}).catch(function(err){
msg.textContent='noVNC konnte nicht geladen werden (Internet?)';
post({event:'vnc_state', state:'error', error:String(err)});
});
})();
</script></body></html>`;
+80 -6
View File
@@ -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<Props> = () => {
const VncTile: React.FC<Props> = ({ projectId, focused }) => {
const webRef = useRef<WebView>(null);
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
const unsubDataRef = useRef<null | (() => 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 (
<View style={styles.container}>
<View style={styles.placeholder}>
<Text style={styles.icon}>🖥</Text>
<Text style={styles.text}>Desktop</Text>
<Text style={styles.sub}>Kein Desktop verbunden</Text>
<Text style={styles.sub}>Antippen zum Verbinden</Text>
</View>
);
}
return (
<View style={styles.container}>
<WebView
ref={webRef}
style={styles.web}
originWhitelist={['*']}
source={{ html: NOVNC_HTML, baseUrl: 'https://aria-vnc.local/' }}
onMessage={onMessage}
javaScriptEnabled
domStorageEnabled
mixedContentMode="always"
androidLayerType="hardware"
/>
{status !== 'connected' && (
<View style={styles.overlay} pointerEvents="none">
<Text style={styles.overlayText}>
{status === 'connecting' ? 'Verbinde …' : status === 'disconnected' ? 'Getrennt' : ''}
</Text>
</View>
)}
</View>
);
};
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;
+130
View File
@@ -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.