Die Software-Tastatur liefert weder Enter (Haken) noch Esc/Pfeile/F-Tasten/ Strg/Alt. Neu: - Haken der Tastatur → onSubmitEditing sendet jetzt Enter (returnKeyType=send). - Fn-Leiste (Toggle in der ctlBar): Esc, Tab, Pfeile, Pos1/Ende/Bild, Einfg/ Entf, Enter, F1–F12, Strg+Alt+Entf — direkt an rfb.sendKey. - Sticky-Modifier Strg/Alt/Shift (one-shot): kombinieren mit der naechsten Taste ODER dem naechsten getippten Zeichen → Strg+C, Strg+Alt+Entf, Alt+F … - noVNC: window.ariaVncKey.combo(mods,ks) haelt Modifier, tappt, laesst los. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
135 lines
6.0 KiB
TypeScript
135 lines
6.0 KiB
TypeScript
/**
|
|
* 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');
|
|
|
|
// Tastatur laeuft NICHT mehr ueber ein verstecktes WebView-Feld (Android
|
|
// oeffnet die Software-Tastatur dafuer unzuverlaessig). Stattdessen haelt die
|
|
// App ein echtes RN-<TextInput> und ruft window.ariaVncKey.* per
|
|
// injectJavaScript auf → wird unten (nach RFB-Init) definiert.
|
|
// cp<0x100 → Keysym == Codepoint (Latin-1)
|
|
// sonst → X11-Unicode-Keysym 0x01000000+cp
|
|
function cpToKeysym(cp){ return cp < 0x100 ? cp : 0x01000000 + cp; }
|
|
|
|
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/', {});
|
|
var fit=true;
|
|
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;
|
|
|
|
// Down+Up einer Taste an die VM schicken.
|
|
function tap(keysym, code){ try{ rfb.sendKey(keysym, code||null, true); rfb.sendKey(keysym, code||null, false); }catch(_){} }
|
|
|
|
// Empfaenger-API: die App (RN-<TextInput> + Sondertasten-Leiste) ruft das
|
|
// per injectJavaScript.
|
|
// char(cp) druckbares Zeichen (Codepoint)
|
|
// keysym(ks) Sondertaste als fertiges X11-Keysym (Enter/Esc/F1/…)
|
|
// combo(mods,ks) Modifier(-Keysyms) halten → Taste → wieder loslassen
|
|
// (Strg+C, Strg+Alt+Entf, …). mods = Array von Keysyms.
|
|
window.ariaVncKey = {
|
|
char: function(cp){ tap(cpToKeysym(cp)); },
|
|
keysym: function(ks){ tap(ks); },
|
|
combo: function(mods, ks){
|
|
try{
|
|
for(var i=0;i<mods.length;i++) rfb.sendKey(mods[i], null, true);
|
|
rfb.sendKey(ks, null, true); rfb.sendKey(ks, null, false);
|
|
for(var j=mods.length-1;j>=0;j--) rfb.sendKey(mods[j], null, false);
|
|
}catch(_){}
|
|
}
|
|
};
|
|
|
|
// Steuerungs-API fuer die App (per injectJavaScript).
|
|
window.ariaVncCtl = {
|
|
cad: function(){ try{ rfb.sendCtrlAltDel(); }catch(_){} },
|
|
toggleFit: function(){ fit=!fit; rfb.scaleViewport=fit; rfb.clipViewport=!fit; post({event:'vnc_fit', fit:fit}); }
|
|
};
|
|
|
|
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>`;
|