Der versteckte WebView-Input oeffnete die Android-Software-Tastatur unzuverlaessig. Jetzt haelt VncTile ein echtes RN-<TextInput> (keyboardType=visible-password), der ⌨-Button fokussiert es. Getippte Zeichen gehen per injectJavaScript an window.ariaVncKey.char/keysym → rfb.sendKey. Prefix-Diff fuer Druckbares, onKeyPress fuer Backspace/Enter. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
207 lines
8.1 KiB
TypeScript
207 lines
8.1 KiB
TypeScript
/**
|
|
* 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. Eine kleine Steuerungs-Leiste macht
|
|
* die VM auf dem Handy bedienbar: Tastatur einblenden (tippt in die VM),
|
|
* Strg-Alt-Entf, und Fit ↔ 1:1 umschalten.
|
|
*/
|
|
|
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
import { NativeSyntheticEvent, 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 VncTile: React.FC<Props> = ({ projectId, focused, port = 5901 }) => {
|
|
const webRef = useRef<WebView>(null);
|
|
const kbdRef = useRef<TextInput>(null);
|
|
const bufRef = useRef(''); // Spiegel des TextInput-Textes
|
|
const [status, setStatus] = useState<'idle' | 'connecting' | 'connected' | 'disconnected'>('idle');
|
|
const [kbdOn, setKbdOn] = useState(false);
|
|
const unsubDataRef = useRef<null | (() => 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]);
|
|
|
|
const ctl = useCallback((fn: string) => {
|
|
webRef.current?.injectJavaScript(`window.ariaVncCtl && window.ariaVncCtl.${fn}(); true;`);
|
|
}, []);
|
|
|
|
const sendChar = useCallback((cp: number) => {
|
|
webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.char(${cp}); true;`);
|
|
}, []);
|
|
const sendKeysym = useCallback((ks: number) => {
|
|
webRef.current?.injectJavaScript(`window.ariaVncKey && window.ariaVncKey.keysym(${ks}); true;`);
|
|
}, []);
|
|
|
|
// Tastatur ein-/ausblenden: echtes RN-<TextInput> fokussieren → Android oeffnet
|
|
// die Software-Tastatur zuverlaessig (anders als ein verstecktes WebView-Feld).
|
|
const toggleKbd = useCallback(() => {
|
|
if (kbdOn) { kbdRef.current?.blur(); setKbdOn(false); }
|
|
else { setKbdOn(true); setTimeout(() => kbdRef.current?.focus(), 0); }
|
|
}, [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<TextInputChangeEventData>) => {
|
|
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) sendChar(cp); }
|
|
bufRef.current = text;
|
|
if (text.length > 200) { bufRef.current = ''; kbdRef.current?.setNativeProps({ text: '' }); }
|
|
}, [sendChar]);
|
|
|
|
// Sondertasten: Backspace/Enter feuern auf Android zuverlaessig als keyPress.
|
|
const onKbdKeyPress = useCallback((e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
const k = e.nativeEvent.key;
|
|
if (k === 'Backspace') sendKeysym(KEYSYM.Backspace);
|
|
else if (k === 'Enter') sendKeysym(KEYSYM.Enter);
|
|
}, [sendKeysym]);
|
|
|
|
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 (
|
|
<View style={styles.placeholder}>
|
|
<Text style={styles.icon}>🖥️</Text>
|
|
<Text style={styles.text}>Desktop</Text>
|
|
<Text style={styles.sub}>Panel öffnen zum Verbinden</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const connected = status === 'connected';
|
|
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"
|
|
keyboardDisplayRequiresUserAction={false}
|
|
/>
|
|
|
|
{/* Verstecktes Eingabefeld: fokussiert → Android-Tastatur tippt in die VM.
|
|
keyboardType=visible-password schaltet Autokorrektur/Vorschlaege ab und
|
|
liefert saubere Einzelzeichen. Offscreen, aber fokussierbar. */}
|
|
<TextInput
|
|
ref={kbdRef}
|
|
style={styles.hiddenInput}
|
|
onChange={onKbdChange}
|
|
onKeyPress={onKbdKeyPress}
|
|
onBlur={() => setKbdOn(false)}
|
|
keyboardType="visible-password"
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
spellCheck={false}
|
|
blurOnSubmit={false}
|
|
caretHidden
|
|
contextMenuHidden
|
|
multiline={false}
|
|
/>
|
|
|
|
{/* Steuerungs-Leiste — nur wenn verbunden */}
|
|
{connected && (
|
|
<View style={styles.ctlBar}>
|
|
<TouchableOpacity style={[styles.ctlBtn, kbdOn && styles.ctlBtnOn]} onPress={toggleKbd} activeOpacity={0.7}>
|
|
<Text style={styles.ctlText}>⌨</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('cad')} activeOpacity={0.7}>
|
|
<Text style={styles.ctlTextSmall}>Strg+Alt+Entf</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity style={styles.ctlBtn} onPress={() => ctl('toggleFit')} activeOpacity={0.7}>
|
|
<Text style={styles.ctlText}>⤢</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
|
|
{!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' },
|
|
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 },
|
|
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;
|