/** * VncTile — Live-Desktop der QEMU-VM (noVNC in einer WebView, RFB durch RVS). * * Nur aktiv, wenn das Desktop-Panel offen ist (focused): dann WebView mounten, * bei 'ready' den RVS-VNC-Tunnel oeffnen. Zwei Bedien-Leisten machen die VM auf * dem Handy voll bedienbar: * - ctlBar (oben rechts): Fn-Leiste ein/aus, Software-Tastatur, Fit ↔ 1:1. * - keyBar (oben, Fn): echte Steuertasten, die keine Software-Tastatur * liefert — Esc, Tab, Pfeile, Pos1/Ende/Bild, Einfg/Entf, Enter, F1–F12 und * Sticky-Modifier Strg/Alt/Shift (fuer Strg+C, Strg+Alt+Entf, …). */ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { Keyboard, NativeSyntheticEvent, ScrollView, StyleSheet, Text, TextInput, TextInputChangeEventData, TextInputKeyPressEventData, TouchableOpacity, 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; port?: number; // VNC-Port der zu verbindenden VM (Default 5901 = Display :1) } // X11-Keysyms fuer Sondertasten, die kein druckbares Zeichen liefern. const KEYSYM = { Backspace: 0xff08, Enter: 0xff0d, Tab: 0xff09 }; const MOD = { ctrl: 0xffe3, alt: 0xffe9, shift: 0xffe1 }; const cpToKeysym = (cp: number) => (cp < 0x100 ? cp : 0x01000000 + cp); // Sondertasten fuer die Fn-Leiste (Label → Keysym). const NAV_KEYS: { label: string; ks: number }[] = [ { label: 'Esc', ks: 0xff1b }, { label: 'Tab', ks: 0xff09 }, { label: '←', ks: 0xff51 }, { label: '↑', ks: 0xff52 }, { label: '↓', ks: 0xff54 }, { label: '→', ks: 0xff53 }, { label: 'Pos1', ks: 0xff50 }, { label: 'Ende', ks: 0xff57 }, { label: 'Bild↑', ks: 0xff55 }, { label: 'Bild↓', ks: 0xff56 }, { label: 'Einfg', ks: 0xff63 }, { label: 'Entf', ks: 0xffff }, { label: '⏎', ks: 0xff0d }, ]; const F_KEYS: { label: string; ks: number }[] = Array.from({ length: 12 }, (_, i) => ({ label: 'F' + (i + 1), ks: 0xffbe + i })); const VncTile: React.FC = ({ projectId, focused, port = 5901 }) => { const webRef = useRef(null); const kbdRef = useRef(null); const bufRef = useRef(''); // Spiegel des TextInput-Textes const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle'); const [kbdOn, setKbdOn] = useState(false); const [keyBar, setKeyBar] = useState(false); // Fn-Leiste sichtbar? const [mods, setMods] = useState({ ctrl: false, alt: false, shift: false }); const modRef = useRef({ ctrl: false, alt: false, shift: false }); // Spiegel fuer Closures const unsubDataRef = useRef void)>(null); const teardown = useCallback(() => { if (unsubDataRef.current) { unsubDataRef.current(); unsubDataRef.current = null; } desktop.closeVnc(); }, []); useEffect(() => { if (!focused) { teardown(); setStatus('idle'); } return () => teardown(); }, [focused, teardown]); // Button-Zustand an die ECHTE Tastatur-Sichtbarkeit koppeln: Androids // Zurueck-Taste blendet die Tastatur aus, ohne den TextInput zu blurren — // ueber keyboardDidHide setzen wir das ⌨-Symbol trotzdem zurueck. useEffect(() => { if (!focused) return; const show = Keyboard.addListener('keyboardDidShow', () => setKbdOn(true)); const hide = Keyboard.addListener('keyboardDidHide', () => setKbdOn(false)); return () => { show.remove(); hide.remove(); }; }, [focused]); const ctl = useCallback((fn: string) => { webRef.current?.injectJavaScript(`window.ariaVncCtl && window.ariaVncCtl.${fn}(); true;`); }, []); const sendKeysym = useCallback((ks: number) => { webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.keysym(${ks}); true;`); }, []); const sendCombo = useCallback((modKeysyms: number[], ks: number) => { webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.combo(${JSON.stringify(modKeysyms)}, ${ks}); true;`); }, []); // Aktive Sticky-Modifier als Keysym-Liste; nach dem Anwenden one-shot zuruecksetzen. const activeMods = useCallback(() => { const m = modRef.current; const a: number[] = []; if (m.ctrl) a.push(MOD.ctrl); if (m.alt) a.push(MOD.alt); if (m.shift) a.push(MOD.shift); return a; }, []); const clearMods = useCallback(() => { if (modRef.current.ctrl || modRef.current.alt || modRef.current.shift) { modRef.current = { ctrl: false, alt: false, shift: false }; setMods(modRef.current); } }, []); const toggleMod = useCallback((k: 'ctrl' | 'alt' | 'shift') => { modRef.current = { ...modRef.current, [k]: !modRef.current[k] }; setMods(modRef.current); }, []); // Eine Taste (fertiges Keysym) senden — mit ggf. aktiven Modifiern. const pressKey = useCallback((ks: number) => { const m = activeMods(); if (m.length) { sendCombo(m, ks); clearMods(); } else sendKeysym(ks); }, [activeMods, sendCombo, clearMods, sendKeysym]); // Ein druckbares Zeichen senden — mit ggf. aktiven Modifiern (Strg+C etc.). const pressChar = useCallback((cp: number) => { const m = activeMods(); if (m.length) { sendCombo(m, cpToKeysym(cp)); clearMods(); } else webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.char(${cp}); true;`); }, [activeMods, sendCombo, clearMods]); // Tastatur ein-/ausblenden. Oeffnen: blur→focus erzwingt das Aufklappen auch // dann, wenn der TextInput noch fokussiert ist (Tastatur per Zurueck-Taste // versteckt). Schliessen: Keyboard.dismiss(); den Button-Zustand setzt der // keyboardDidShow/Hide-Listener — nicht hier —, damit er nie „haengen" bleibt. const toggleKbd = useCallback(() => { if (kbdOn) { Keyboard.dismiss(); } else { kbdRef.current?.blur(); setTimeout(() => kbdRef.current?.focus(), 30); } }, [kbdOn]); // Druckbare Zeichen: Prefix-Diff des (wachsenden) Feldes → nur neu Getipptes an // die VM. Loeschungen kommen ueber onKeyPress(Backspace), daher hier nur Inserts. const onKbdChange = useCallback((e: NativeSyntheticEvent) => { const text = e.nativeEvent.text || ''; const prev = bufRef.current; let i = 0; const min = Math.min(prev.length, text.length); while (i < min && prev.charCodeAt(i) === text.charCodeAt(i)) i++; for (const ch of text.slice(i)) { const cp = ch.codePointAt(0); if (cp) pressChar(cp); } bufRef.current = text; if (text.length > 200) { bufRef.current = ''; kbdRef.current?.setNativeProps({ text: '' }); } }, [pressChar]); // Sondertasten der Software-Tastatur: Backspace feuert auf Android zuverlaessig // als keyPress; die Return-Taste (Haken) kommt als onSubmitEditing (s.u.). const onKbdKeyPress = useCallback((e: NativeSyntheticEvent) => { const k = e.nativeEvent.key; if (k === 'Backspace') pressKey(KEYSYM.Backspace); else if (k === 'Enter') pressKey(KEYSYM.Enter); }, [pressKey]); const onMessage = useCallback((e: WebViewMessageEvent) => { let m: any; try { m = JSON.parse(e.nativeEvent.data); } catch { return; } if (m.event === 'ready') { 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, port); } 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, port]); if (!focused) { return ( 🖥️ Desktop Panel öffnen zum Verbinden ); } const connected = status === 'connected'; return ( {/* Verstecktes Eingabefeld: fokussiert → Android-Tastatur tippt in die VM. keyboardType=visible-password schaltet Autokorrektur/Vorschlaege ab und liefert saubere Einzelzeichen. Offscreen, aber fokussierbar. */} pressKey(KEYSYM.Enter)} keyboardType="visible-password" returnKeyType="send" autoCapitalize="none" autoCorrect={false} spellCheck={false} blurOnSubmit={false} caretHidden contextMenuHidden multiline={false} /> {/* Steuerungs-Leiste — nur wenn verbunden */} {connected && ( setKeyBar(v => !v)} activeOpacity={0.7}> Fn ctl('toggleFit')} activeOpacity={0.7}> )} {/* Fn-Leiste — echte Steuertasten (oben, ueber der Software-Tastatur). */} {connected && keyBar && ( toggleMod('ctrl')} activeOpacity={0.7}>Strg toggleMod('alt')} activeOpacity={0.7}>Alt toggleMod('shift')} activeOpacity={0.7}>Shift {NAV_KEYS.map(k => ( pressKey(k.ks)} activeOpacity={0.7}>{k.label} ))} {F_KEYS.map(k => ( pressKey(k.ks)} activeOpacity={0.7}>{k.label} ))} ctl('cad')} activeOpacity={0.7}>Strg+Alt+Entf )} {!connected && ( {status === 'connecting' ? 'Verbinde …' : status === 'disconnected' ? 'Getrennt' : ''} )} ); }; const styles = StyleSheet.create({ 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 }, ctlBar: { position: 'absolute', top: 34, right: 8, flexDirection: 'row', gap: 6, }, ctlBtn: { backgroundColor: 'rgba(18,18,42,0.9)', borderColor: '#2A2A3E', borderWidth: 1, borderRadius: 10, paddingHorizontal: 10, paddingVertical: 7, minWidth: 38, alignItems: 'center', justifyContent: 'center', }, ctlBtnOn: { backgroundColor: 'rgba(0,150,255,0.85)', borderColor: '#0096FF' }, ctlText: { color: '#E0E0F0', fontSize: 16, fontWeight: '700' }, ctlTextSmall: { color: '#E0E0F0', fontSize: 11, fontWeight: '700' }, // Fokussierbar (nicht display:none), aber aus dem Sichtfeld geschoben. hiddenInput: { position: 'absolute', width: 1, height: 1, top: -100, left: -100, opacity: 0, padding: 0 }, keyBar: { position: 'absolute', top: 74, left: 0, right: 0, gap: 5 }, keyRow: { paddingHorizontal: 6, gap: 5, alignItems: 'center' }, key: { backgroundColor: 'rgba(18,18,42,0.92)', borderColor: '#2A2A3E', borderWidth: 1, borderRadius: 8, paddingHorizontal: 9, paddingVertical: 7, minWidth: 34, alignItems: 'center', justifyContent: 'center', }, keyOn: { backgroundColor: 'rgba(0,150,255,0.85)', borderColor: '#0096FF' }, keyText: { color: '#E0E0F0', fontSize: 13, fontWeight: '700' }, keyTextSm: { color: '#E0E0F0', fontSize: 10, fontWeight: '700' }, overlay: { position: 'absolute', top: 12, 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;