Add native LAN VNC streaming backend

This commit is contained in:
litoral05
2026-06-01 16:28:57 +01:00
parent ba52c1516b
commit 6ef1e83e63
10 changed files with 3147 additions and 0 deletions
@@ -0,0 +1,38 @@
package com.litoralregas.backend.vnc.rfb;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
public class DesCipherDesktop {
private final Cipher encryptCipher;
public DesCipherDesktop(byte[] keyBytes) throws Exception {
byte[] material = new byte[8];
System.arraycopy(keyBytes, 0, material, 0, Math.min(keyBytes.length, 8));
for (int i = 0; i < material.length; i++) {
material[i] = reverseBits(material[i]);
}
SecretKey key = new SecretKeySpec(material, "DES");
encryptCipher = Cipher.getInstance("DES/ECB/NoPadding");
encryptCipher.init(Cipher.ENCRYPT_MODE, key);
}
public void encrypt(byte[] src, int srcOff, byte[] dst, int dstOff) throws Exception {
byte[] out = encryptCipher.doFinal(src, srcOff, 8);
System.arraycopy(out, 0, dst, dstOff, 8);
}
private byte reverseBits(byte b) {
int v = b & 0xFF;
int r = 0;
for (int i = 0; i < 8; i++) {
r = (r << 1) | (v & 1);
v >>= 1;
}
return (byte) r;
}
}
@@ -0,0 +1,294 @@
package com.litoralregas.backend.vnc.rfb;
import java.util.Arrays;
public class DesktopBitmapData {
private int framebufferWidth;
private int framebufferHeight;
private byte[] pixelBuffer;
private byte[] copyBuffer;
private byte[] rowTemplateBuffer;
private static final int MAX_DIRTY_RECTS = 64;
private boolean dirty = true;
private int dirtyRectCount = 0;
private final int[] dirtyXs = new int[MAX_DIRTY_RECTS];
private final int[] dirtyYs = new int[MAX_DIRTY_RECTS];
private final int[] dirtyWs = new int[MAX_DIRTY_RECTS];
private final int[] dirtyHs = new int[MAX_DIRTY_RECTS];
private int dirtyMinX;
private int dirtyMinY;
private int dirtyMaxX;
private int dirtyMaxY;
public DesktopBitmapData(int width, int height) {
resize(width, height);
}
public void resize(int width, int height) {
this.framebufferWidth = Math.max(1, width);
this.framebufferHeight = Math.max(1, height);
this.pixelBuffer = new byte[framebufferWidth * framebufferHeight * 4];
this.copyBuffer = null;
this.rowTemplateBuffer = null;
for (int i = 3; i < pixelBuffer.length; i += 4) {
pixelBuffer[i] = (byte) 255;
}
this.dirty = false;
markDirty(0, 0, framebufferWidth, framebufferHeight);
}
public int getFramebufferWidth() {
return framebufferWidth;
}
public int getFramebufferHeight() {
return framebufferHeight;
}
public byte[] getPixelBuffer() {
return pixelBuffer;
}
public byte[] copyPixelBuffer() {
return Arrays.copyOf(pixelBuffer, pixelBuffer.length);
}
public boolean isDirty() {
return dirty;
}
public int getDirtyRectCount() {
return dirtyRectCount;
}
public int[] getDirtyXs() {
return dirtyXs;
}
public int[] getDirtyYs() {
return dirtyYs;
}
public int[] getDirtyWs() {
return dirtyWs;
}
public int[] getDirtyHs() {
return dirtyHs;
}
public void clearDirty() {
dirty = false;
dirtyRectCount = 0;
dirtyMinX = 0;
dirtyMinY = 0;
dirtyMaxX = 0;
dirtyMaxY = 0;
}
public boolean validDraw(int x, int y, int w, int h) {
return w > 0
&& h > 0
&& x >= 0
&& y >= 0
&& x + w <= framebufferWidth
&& y + h <= framebufferHeight;
}
public int offset(int x, int y) {
return (y * framebufferWidth + x) * 4;
}
private void markDirty(int x, int y, int w, int h) {
if (w <= 0 || h <= 0) return;
int minX = Math.max(0, x);
int minY = Math.max(0, y);
int maxX = Math.min(framebufferWidth, x + w);
int maxY = Math.min(framebufferHeight, y + h);
if (minX >= maxX || minY >= maxY) return;
if (!dirty) {
dirty = true;
dirtyRectCount = 0;
dirtyMinX = minX;
dirtyMinY = minY;
dirtyMaxX = maxX;
dirtyMaxY = maxY;
} else {
dirtyMinX = Math.min(dirtyMinX, minX);
dirtyMinY = Math.min(dirtyMinY, minY);
dirtyMaxX = Math.max(dirtyMaxX, maxX);
dirtyMaxY = Math.max(dirtyMaxY, maxY);
}
if (dirtyRectCount >= 0 && dirtyRectCount < MAX_DIRTY_RECTS) {
dirtyXs[dirtyRectCount] = minX;
dirtyYs[dirtyRectCount] = minY;
dirtyWs[dirtyRectCount] = maxX - minX;
dirtyHs[dirtyRectCount] = maxY - minY;
dirtyRectCount++;
} else {
dirtyRectCount = -1;
}
}
public void fillRect(int x, int y, int w, int h, int color) {
if (w <= 0 || h <= 0) return;
int startX = Math.max(0, x);
int startY = Math.max(0, y);
int endX = Math.min(framebufferWidth, x + w);
int endY = Math.min(framebufferHeight, y + h);
if (startX >= endX || startY >= endY) return;
int clippedW = endX - startX;
int clippedH = endY - startY;
int rowBytes = clippedW * 4;
if (rowTemplateBuffer == null || rowTemplateBuffer.length < rowBytes) {
rowTemplateBuffer = new byte[rowBytes];
}
byte b = (byte) (color & 0xFF);
byte g = (byte) ((color >> 8) & 0xFF);
byte r = (byte) ((color >> 16) & 0xFF);
byte a = (byte) 255;
for (int i = 0; i < rowBytes; i += 4) {
rowTemplateBuffer[i] = b;
rowTemplateBuffer[i + 1] = g;
rowTemplateBuffer[i + 2] = r;
rowTemplateBuffer[i + 3] = a;
}
for (int yy = startY; yy < endY; yy++) {
System.arraycopy(
rowTemplateBuffer,
0,
pixelBuffer,
offset(startX, yy),
rowBytes
);
}
markDirty(startX, startY, clippedW, clippedH);
}
public void copyRect(int srcX, int srcY, int dstX, int dstY, int w, int h) {
if (w <= 0 || h <= 0) return;
if (!validDraw(srcX, srcY, w, h)) return;
if (!validDraw(dstX, dstY, w, h)) return;
int rowBytes = w * 4;
boolean overlap =
dstY < srcY + h &&
srcY < dstY + h &&
dstX < srcX + w &&
srcX < dstX + w;
if (!overlap) {
for (int row = 0; row < h; row++) {
System.arraycopy(
pixelBuffer,
offset(srcX, srcY + row),
pixelBuffer,
offset(dstX, dstY + row),
rowBytes
);
}
markDirty(dstX, dstY, w, h);
return;
}
int required = rowBytes * h;
if (copyBuffer == null || copyBuffer.length < required) {
copyBuffer = new byte[required];
}
for (int row = 0; row < h; row++) {
System.arraycopy(
pixelBuffer,
offset(srcX, srcY + row),
copyBuffer,
row * rowBytes,
rowBytes
);
}
for (int row = 0; row < h; row++) {
System.arraycopy(
copyBuffer,
row * rowBytes,
pixelBuffer,
offset(dstX, dstY + row),
rowBytes
);
}
markDirty(dstX, dstY, w, h);
}
public void setRgbPixels(
int x,
int y,
int w,
int h,
int[] src,
int srcOffset,
int srcStride
) {
if (src == null || w <= 0 || h <= 0) return;
int startX = Math.max(0, x);
int startY = Math.max(0, y);
int endX = Math.min(framebufferWidth, x + w);
int endY = Math.min(framebufferHeight, y + h);
if (startX >= endX || startY >= endY) return;
int clippedW = endX - startX;
int clippedH = endY - startY;
int srcXOffset = startX - x;
int srcYOffset = startY - y;
for (int row = 0; row < clippedH; row++) {
int srcIndex = srcOffset + (srcYOffset + row) * srcStride + srcXOffset;
int dstIndex = offset(startX, startY + row);
for (int col = 0; col < clippedW; col++) {
int color = src[srcIndex++];
pixelBuffer[dstIndex++] = (byte) (color & 0xFF);
pixelBuffer[dstIndex++] = (byte) ((color >> 8) & 0xFF);
pixelBuffer[dstIndex++] = (byte) ((color >> 16) & 0xFF);
pixelBuffer[dstIndex++] = (byte) 255;
}
}
markDirty(startX, startY, clippedW, clippedH);
}
public void markFullDirty() {
markDirty(0, 0, framebufferWidth, framebufferHeight);
}
public void markSingleDirtyRect(int x, int y, int w, int h) {
markDirty(x, y, w, h);
}
}
@@ -0,0 +1,60 @@
package com.litoralregas.backend.vnc.rfb;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import java.net.SocketTimeoutException;
public class GreetClient implements AutoCloseable {
private Socket clientSocket;
private PrintWriter out;
private BufferedReader in;
public void startConnection(String ip, int port) throws IOException {
clientSocket = new Socket(ip, port);
clientSocket.setSoTimeout(10000);
out = new PrintWriter(clientSocket.getOutputStream(), true);
in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
}
public String sendMessage(String msg) throws IOException {
if (out == null || in == null) {
throw new IllegalStateException("Connection not started");
}
out.println(msg);
String response = in.readLine();
if (response == null) {
throw new SocketTimeoutException("No response from server");
}
return response;
}
public void stopConnection() {
try {
if (in != null) in.close();
} catch (IOException ignored) {
}
if (out != null) {
out.close();
}
try {
if (clientSocket != null && !clientSocket.isClosed()) {
clientSocket.close();
}
} catch (IOException ignored) {
}
}
@Override
public void close() {
stopConnection();
}
}
@@ -0,0 +1,56 @@
package com.litoralregas.backend.vnc.rfb;
public abstract class InStreamDesktop {
protected byte[] b;
protected int ptr;
protected int end;
public byte[] getbuf() { return b; }
public int getptr() { return ptr; }
public int getend() { return end; }
public void setptr(int p) { ptr = p; }
public void check(int itemSize) throws Exception {
if (ptr + itemSize > end) {
overrun(itemSize, 1);
}
}
public int readU8() throws Exception {
check(1);
return b[ptr++] & 0xFF;
}
public int readU16() throws Exception {
check(2);
int v = ((b[ptr] & 0xFF) << 8) | (b[ptr + 1] & 0xFF);
ptr += 2;
return v;
}
public int readS32() throws Exception {
check(4);
int v = ((b[ptr] & 0xFF) << 24)
| ((b[ptr + 1] & 0xFF) << 16)
| ((b[ptr + 2] & 0xFF) << 8)
| (b[ptr + 3] & 0xFF);
ptr += 4;
return v;
}
public void readBytes(byte[] dst, int off, int len) throws Exception {
while (len > 0) {
if (ptr >= end) {
overrun(1, 1);
}
int n = Math.min(len, end - ptr);
System.arraycopy(b, ptr, dst, off, n);
ptr += n;
off += n;
len -= n;
}
}
protected abstract int overrun(int itemSize, int nItems) throws Exception;
}
@@ -0,0 +1,19 @@
package com.litoralregas.backend.vnc.rfb;
public class MemInStreamDesktop extends InStreamDesktop {
public MemInStreamDesktop(byte[] data, int offset, int len) {
b = data;
ptr = offset;
end = offset + len;
}
public int pos() {
return ptr;
}
@Override
protected int overrun(int itemSize, int nItems) throws Exception {
throw new Exception("MemInStream overrun: end of stream");
}
}
@@ -0,0 +1,568 @@
package com.litoralregas.backend.vnc.rfb;
import java.io.*;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
public class RfbProtoDesktop {
public static final String VERSION_MSG_3_3 = "RFB 003.003\n";
public static final String VERSION_MSG_3_7 = "RFB 003.007\n";
public static final String VERSION_MSG_3_8 = "RFB 003.008\n";
public static final int SEC_TYPE_INVALID = 0;
public static final int SEC_TYPE_NONE = 1;
public static final int SEC_TYPE_VNC_AUTH = 2;
public static final int SEC_TYPE_TIGHT = 16;
public static final int SEC_TYPE_ULTRA_34 = 0xfffffffa;
public static final int VNC_AUTH_OK = 0;
public static final int VNC_AUTH_FAILED = 1;
public static final int VNC_AUTH_TOO_MANY = 2;
public static final int FRAMEBUFFER_UPDATE = 0;
public static final int SET_COLOUR_MAP_ENTRIES = 1;
public static final int BELL = 2;
public static final int SET_PIXEL_FORMAT = 0;
public static final int SET_ENCODINGS = 2;
public static final int FRAMEBUFFER_UPDATE_REQUEST = 3;
public static final int POINTER_EVENT = 5;
public static final int ENCODING_RAW = 0;
public static final int ENCODING_COPY_RECT = 1;
public static final int ENCODING_RRE = 2;
public static final int ENCODING_CORRE = 4;
public static final int ENCODING_HEXTILE = 5;
public static final int ENCODING_ZLIB = 6;
public static final int ENCODING_ZRLE = 16;
public static final int ENCODING_TIGHT = 7;
public static final int ENCODING_X_CURSOR = 0xFFFFFF10;
public static final int ENCODING_RICH_CURSOR = 0xFFFFFF11;
public static final int ENCODING_POINTER_POS = 0xFFFFFF18;
public static final int ENCODING_LAST_RECT = 0xFFFFFF20;
public static final int ENCODING_NEW_FB_SIZE = 0xFFFFFF21;
public static final int HEXTILE_RAW = 1;
public static final int HEXTILE_BACKGROUND_SPECIFIED = 2;
public static final int HEXTILE_FOREGROUND_SPECIFIED = 4;
public static final int HEXTILE_ANY_SUBRECTS = 8;
public static final int HEXTILE_SUBRECTS_COLOURED = 16;
private static final int CONNECT_TIMEOUT_MS = 8000;
private static final int READ_TIMEOUT_MS = 30000;
private static final int IO_BUFFER_SIZE = 65536;
private final String host;
private final int port;
private final Socket sock;
private final DataInputStream is;
private final OutputStream os;
private boolean inNormalProtocol = false;
private boolean wereZlibUpdates = false;
private int serverMajor;
private int serverMinor;
private int clientMajor;
private int clientMinor;
private String desktopName;
private int framebufferWidth;
private int framebufferHeight;
private int bitsPerPixel;
private int depth;
private boolean bigEndian;
private boolean trueColour;
private int redMax;
private int greenMax;
private int blueMax;
private int redShift;
private int greenShift;
private int blueShift;
private int updateNRects;
private int updateRectX;
private int updateRectY;
private int updateRectW;
private int updateRectH;
private int updateRectEncoding;
private int copyRectSrcX;
private int copyRectSrcY;
private final byte[] framebufferUpdateRequest = new byte[10];
private byte[] setEncodingsBuf = new byte[64];
private final byte[] eventBuf = new byte[256];
private int eventBufLen;
public RfbProtoDesktop(String host, int port) throws IOException {
this.host = host;
this.port = port;
this.sock = new Socket();
// DO NOT set TCP_NODELAY — Nagle coalesces small writes (pointer
// events, framebuffer requests) into fewer packets, which massively
// reduces per-packet overhead on the udp2raw/WireGuard path.
this.sock.setKeepAlive(true);
this.sock.setReuseAddress(true);
this.sock.connect(new InetSocketAddress(host, port), CONNECT_TIMEOUT_MS);
this.sock.setSoTimeout(READ_TIMEOUT_MS);
// Increase receive buffer — large ZRLE/Tight frames arrive in bursts.
this.sock.setReceiveBufferSize(65536);
this.is = new DataInputStream(new BufferedInputStream(sock.getInputStream(), IO_BUFFER_SIZE));
this.os = new BufferedOutputStream(sock.getOutputStream(), IO_BUFFER_SIZE);
}
public DataInputStream getInputStream() {
return is;
}
public boolean isInNormalProtocol() {
return inNormalProtocol;
}
public boolean hasZlibUpdates() {
return wereZlibUpdates;
}
public int getServerMajor() { return serverMajor; }
public int getServerMinor() { return serverMinor; }
public int getClientMajor() { return clientMajor; }
public int getClientMinor() { return clientMinor; }
public String getDesktopName() { return desktopName; }
public int getFramebufferWidth() { return framebufferWidth; }
public int getFramebufferHeight() { return framebufferHeight; }
public int getBitsPerPixel() { return bitsPerPixel; }
public int getDepth() { return depth; }
public boolean isBigEndian() { return bigEndian; }
public boolean isTrueColour() { return trueColour; }
public int getRedMax() { return redMax; }
public int getGreenMax() { return greenMax; }
public int getBlueMax() { return blueMax; }
public int getRedShift() { return redShift; }
public int getGreenShift() { return greenShift; }
public int getBlueShift() { return blueShift; }
public int getUpdateNRects() { return updateNRects; }
public int getUpdateRectX() { return updateRectX; }
public int getUpdateRectY() { return updateRectY; }
public int getUpdateRectW() { return updateRectW; }
public int getUpdateRectH() { return updateRectH; }
public int getUpdateRectEncoding() { return updateRectEncoding; }
public int getCopyRectSrcX() { return copyRectSrcX; }
public int getCopyRectSrcY() { return copyRectSrcY; }
public synchronized void close() {
try {
sock.close();
} catch (Exception ignored) {
}
}
public void readVersionMsg() throws Exception {
byte[] b = new byte[12];
readFully(b);
if ((b[0] != 'R') || (b[1] != 'F') || (b[2] != 'B') || (b[3] != ' ')
|| (b[4] < '0') || (b[4] > '9') || (b[5] < '0') || (b[5] > '9')
|| (b[6] < '0') || (b[6] > '9') || (b[7] != '.')
|| (b[8] < '0') || (b[8] > '9') || (b[9] < '0') || (b[9] > '9')
|| (b[10] < '0') || (b[10] > '9') || (b[11] != '\n')) {
throw new Exception("Host " + host + " port " + port + " is not an RFB server");
}
serverMajor = (b[4] - '0') * 100 + (b[5] - '0') * 10 + (b[6] - '0');
serverMinor = (b[8] - '0') * 100 + (b[9] - '0') * 10 + (b[10] - '0');
if (serverMajor < 3) {
throw new Exception("RFB server does not support protocol version 3");
}
}
public synchronized void writeVersionMsg() throws IOException {
clientMajor = 3;
if (serverMajor > 3 || serverMinor >= 8) {
clientMinor = 8;
os.write(VERSION_MSG_3_8.getBytes(StandardCharsets.US_ASCII));
} else if (serverMinor >= 7) {
clientMinor = 7;
os.write(VERSION_MSG_3_7.getBytes(StandardCharsets.US_ASCII));
} else {
clientMinor = 3;
os.write(VERSION_MSG_3_3.getBytes(StandardCharsets.US_ASCII));
}
os.flush();
}
public int negotiateSecurity() throws Exception {
return (clientMinor >= 7) ? selectSecurityType() : readSecurityType();
}
public int readSecurityType() throws Exception {
int secType = is.readInt();
switch (secType) {
case SEC_TYPE_INVALID:
readConnFailedReason();
return SEC_TYPE_INVALID;
case SEC_TYPE_NONE:
case SEC_TYPE_VNC_AUTH:
return secType;
default:
throw new Exception("Unknown security type from RFB server: " + secType);
}
}
int selectSecurityType() throws Exception {
int nSecTypes = is.readUnsignedByte();
if (nSecTypes == 0) {
readConnFailedReason();
return SEC_TYPE_INVALID;
}
byte[] secTypes = new byte[nSecTypes];
readFully(secTypes);
int selected = SEC_TYPE_INVALID;
// Prefer VNC auth over no-auth if both are offered.
for (byte secType : secTypes) {
if ((secType & 0xFF) == SEC_TYPE_VNC_AUTH) {
selected = SEC_TYPE_VNC_AUTH;
break;
}
}
if (selected == SEC_TYPE_INVALID) {
for (byte secType : secTypes) {
if ((secType & 0xFF) == SEC_TYPE_NONE) {
selected = SEC_TYPE_NONE;
break;
}
}
}
if (selected == SEC_TYPE_INVALID) {
throw new Exception("Server did not offer supported security type");
}
os.write(selected);
os.flush();
return selected;
}
public void authenticateVNC(String pw) throws Exception {
byte[] challenge = new byte[16];
readFully(challenge);
if (pw == null) {
pw = "";
}
if (pw.length() > 8) pw = pw.substring(0, 8);
int firstZero = pw.indexOf(0);
if (firstZero != -1) pw = pw.substring(0, firstZero);
byte[] key = new byte[8];
byte[] pwBytes = pw.getBytes(StandardCharsets.ISO_8859_1);
System.arraycopy(pwBytes, 0, key, 0, Math.min(pwBytes.length, key.length));
DesCipherDesktop des = new DesCipherDesktop(key);
des.encrypt(challenge, 0, challenge, 0);
des.encrypt(challenge, 8, challenge, 8);
os.write(challenge);
os.flush();
readSecurityResult("VNC authentication");
}
public void readSecurityResult(String authType) throws Exception {
int securityResult = is.readInt();
switch (securityResult) {
case VNC_AUTH_OK:
return;
case VNC_AUTH_FAILED:
if (clientMinor >= 8) readConnFailedReason();
throw new Exception(authType + ": failed");
case VNC_AUTH_TOO_MANY:
throw new Exception(authType + ": failed, too many tries");
default:
throw new Exception(authType + ": unknown result " + securityResult);
}
}
void readConnFailedReason() throws Exception {
int reasonLen = is.readInt();
if (reasonLen < 0 || reasonLen > 1024 * 1024) {
throw new IOException("Invalid RFB failure reason length: " + reasonLen);
}
byte[] reason = new byte[reasonLen];
readFully(reason);
throw new Exception(new String(reason, StandardCharsets.UTF_8));
}
public synchronized void writeClientInit() throws IOException {
os.write(1);
os.flush();
}
public void readServerInit() throws IOException {
framebufferWidth = is.readUnsignedShort();
framebufferHeight = is.readUnsignedShort();
bitsPerPixel = is.readUnsignedByte();
depth = is.readUnsignedByte();
bigEndian = (is.readUnsignedByte() != 0);
trueColour = (is.readUnsignedByte() != 0);
redMax = is.readUnsignedShort();
greenMax = is.readUnsignedShort();
blueMax = is.readUnsignedShort();
redShift = is.readUnsignedByte();
greenShift = is.readUnsignedByte();
blueShift = is.readUnsignedByte();
skipFully(3);
int nameLength = is.readInt();
if (nameLength < 0 || nameLength > 16 * 1024 * 1024) {
throw new IOException("Invalid desktop name length: " + nameLength);
}
byte[] name = new byte[nameLength];
readFully(name);
desktopName = new String(name, StandardCharsets.UTF_8);
inNormalProtocol = true;
}
public void setFramebufferSize(int width, int height) {
framebufferWidth = Math.max(0, width);
framebufferHeight = Math.max(0, height);
}
public int readServerMessageType() throws IOException {
return is.readUnsignedByte();
}
public void readFramebufferUpdate() throws IOException {
is.readUnsignedByte(); // padding
updateNRects = is.readUnsignedShort();
}
public void readFramebufferUpdateRectHdr() throws Exception {
updateRectX = is.readUnsignedShort();
updateRectY = is.readUnsignedShort();
updateRectW = is.readUnsignedShort();
updateRectH = is.readUnsignedShort();
updateRectEncoding = is.readInt();
if (updateRectEncoding == ENCODING_ZLIB || updateRectEncoding == ENCODING_ZRLE) {
wereZlibUpdates = true;
}
if (updateRectEncoding == ENCODING_LAST_RECT) {
updateNRects = 0;
return;
}
if (updateRectEncoding == ENCODING_NEW_FB_SIZE) {
setFramebufferSize(updateRectW, updateRectH);
return;
}
if (updateRectEncoding == ENCODING_POINTER_POS) {
return;
}
// Pseudo-encodings are negative. Their payload handling is done by the caller.
if (updateRectEncoding < 0) {
return;
}
updateRectX = Math.max(0, Math.min(updateRectX, framebufferWidth));
updateRectY = Math.max(0, Math.min(updateRectY, framebufferHeight));
updateRectW = Math.max(0, Math.min(updateRectW, framebufferWidth - updateRectX));
updateRectH = Math.max(0, Math.min(updateRectH, framebufferHeight - updateRectY));
if (updateRectX + updateRectW > framebufferWidth || updateRectY + updateRectH > framebufferHeight) {
throw new Exception("Framebuffer update rectangle too large: " + updateRectW + "x" + updateRectH
+ " at (" + updateRectX + "," + updateRectY + ")");
}
}
public void readCopyRect() throws IOException {
copyRectSrcX = is.readUnsignedShort();
copyRectSrcY = is.readUnsignedShort();
}
public synchronized void writeFramebufferUpdateRequest(int x, int y, int w, int h, boolean incremental) throws IOException {
framebufferUpdateRequest[0] = (byte) FRAMEBUFFER_UPDATE_REQUEST;
framebufferUpdateRequest[1] = (byte) (incremental ? 1 : 0);
framebufferUpdateRequest[2] = (byte) ((x >> 8) & 0xff);
framebufferUpdateRequest[3] = (byte) (x & 0xff);
framebufferUpdateRequest[4] = (byte) ((y >> 8) & 0xff);
framebufferUpdateRequest[5] = (byte) (y & 0xff);
framebufferUpdateRequest[6] = (byte) ((w >> 8) & 0xff);
framebufferUpdateRequest[7] = (byte) (w & 0xff);
framebufferUpdateRequest[8] = (byte) ((h >> 8) & 0xff);
framebufferUpdateRequest[9] = (byte) (h & 0xff);
os.write(framebufferUpdateRequest);
os.flush();
}
public synchronized void writeSetPixelFormat(
int bitsPerPixel, int depth, boolean bigEndian, boolean trueColour,
int redMax, int greenMax, int blueMax,
int redShift, int greenShift, int blueShift, boolean greyScale
) throws IOException {
byte[] b = new byte[20];
b[0] = (byte) SET_PIXEL_FORMAT;
b[1] = 0;
b[2] = 0;
b[3] = 0;
b[4] = (byte) bitsPerPixel;
b[5] = (byte) depth;
b[6] = (byte) (bigEndian ? 1 : 0);
b[7] = (byte) (trueColour ? 1 : 0);
b[8] = (byte) ((redMax >> 8) & 0xff);
b[9] = (byte) (redMax & 0xff);
b[10] = (byte) ((greenMax >> 8) & 0xff);
b[11] = (byte) (greenMax & 0xff);
b[12] = (byte) ((blueMax >> 8) & 0xff);
b[13] = (byte) (blueMax & 0xff);
b[14] = (byte) redShift;
b[15] = (byte) greenShift;
b[16] = (byte) blueShift;
b[17] = (byte) (greyScale ? 1 : 0);
b[18] = 0;
b[19] = 0;
os.write(b);
os.flush();
}
public synchronized void writeSetEncodings(int[] encs, int len) throws IOException {
int required = 4 + 4 * len;
if (setEncodingsBuf.length < required) {
setEncodingsBuf = new byte[Math.max(required, setEncodingsBuf.length * 2)];
}
setEncodingsBuf[0] = (byte) SET_ENCODINGS;
setEncodingsBuf[1] = 0;
setEncodingsBuf[2] = (byte) ((len >> 8) & 0xff);
setEncodingsBuf[3] = (byte) (len & 0xff);
for (int i = 0; i < len; i++) {
int enc = encs[i];
int p = 4 + 4 * i;
setEncodingsBuf[p] = (byte) ((enc >> 24) & 0xff);
setEncodingsBuf[p + 1] = (byte) ((enc >> 16) & 0xff);
setEncodingsBuf[p + 2] = (byte) ((enc >> 8) & 0xff);
setEncodingsBuf[p + 3] = (byte) (enc & 0xff);
}
os.write(setEncodingsBuf, 0, required);
os.flush();
}
public synchronized void writePointerEvent(int x, int y, int modifiers, int pointerMask) throws IOException {
eventBufLen = 0;
appendPointerEvent(x, y, pointerMask);
os.write(eventBuf, 0, eventBufLen);
os.flush();
}
/**
* Lower-latency click path: sends up/down/up plus one incremental framebuffer request
* in a single write/flush instead of four separate writes/flushes.
*/
public synchronized void writeClickAndFramebufferRequest(
int x,
int y,
int framebufferWidth,
int framebufferHeight
) throws IOException {
eventBufLen = 0;
appendPointerEvent(x, y, 0);
appendPointerEvent(x, y, 1);
appendPointerEvent(x, y, 0);
appendFramebufferUpdateRequest(0, 0, framebufferWidth, framebufferHeight, true);
os.write(eventBuf, 0, eventBufLen);
os.flush();
}
/**
* Useful for drag/move support: caller can coalesce many pointer moves and flush once.
*/
public synchronized void writePointerEventNoFlush(int x, int y, int pointerMask) throws IOException {
eventBufLen = 0;
appendPointerEvent(x, y, pointerMask);
os.write(eventBuf, 0, eventBufLen);
}
public synchronized void flush() throws IOException {
os.flush();
}
private void appendPointerEvent(int x, int y, int pointerMask) {
ensureEventCapacity(eventBufLen + 6);
eventBuf[eventBufLen++] = (byte) POINTER_EVENT;
eventBuf[eventBufLen++] = (byte) pointerMask;
eventBuf[eventBufLen++] = (byte) ((x >> 8) & 0xff);
eventBuf[eventBufLen++] = (byte) (x & 0xff);
eventBuf[eventBufLen++] = (byte) ((y >> 8) & 0xff);
eventBuf[eventBufLen++] = (byte) (y & 0xff);
}
private void appendFramebufferUpdateRequest(int x, int y, int w, int h, boolean incremental) {
ensureEventCapacity(eventBufLen + 10);
eventBuf[eventBufLen++] = (byte) FRAMEBUFFER_UPDATE_REQUEST;
eventBuf[eventBufLen++] = (byte) (incremental ? 1 : 0);
eventBuf[eventBufLen++] = (byte) ((x >> 8) & 0xff);
eventBuf[eventBufLen++] = (byte) (x & 0xff);
eventBuf[eventBufLen++] = (byte) ((y >> 8) & 0xff);
eventBuf[eventBufLen++] = (byte) (y & 0xff);
eventBuf[eventBufLen++] = (byte) ((w >> 8) & 0xff);
eventBuf[eventBufLen++] = (byte) (w & 0xff);
eventBuf[eventBufLen++] = (byte) ((h >> 8) & 0xff);
eventBuf[eventBufLen++] = (byte) (h & 0xff);
}
private void ensureEventCapacity(int needed) {
if (needed > eventBuf.length) {
throw new IllegalStateException("Internal event buffer too small: " + needed);
}
}
public void readFully(byte[] b) throws IOException {
readFully(b, 0, b.length);
}
public void readFully(byte[] b, int off, int len) throws IOException {
is.readFully(b, off, len);
}
public void skipFully(int len) throws IOException {
int remaining = len;
while (remaining > 0) {
int skipped = is.skipBytes(remaining);
if (skipped <= 0) {
if (is.read() == -1) {
throw new EOFException("Unexpected EOF while skipping " + len + " bytes");
}
skipped = 1;
}
remaining -= skipped;
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,97 @@
package com.litoralregas.backend.vnc.rfb;
import java.util.zip.DataFormatException;
import java.util.zip.Inflater;
public class ZlibInStreamDesktop extends InStreamDesktop {
static final int DEFAULT_BUF_SIZE = 16384;
private InStreamDesktop underlying;
private int bufSize;
private int ptrOffset;
private Inflater inflater;
private int bytesIn;
public ZlibInStreamDesktop(int bufSize) {
this.bufSize = bufSize;
this.b = new byte[bufSize];
this.ptr = this.end = this.ptrOffset = 0;
this.inflater = new Inflater();
}
public ZlibInStreamDesktop() {
this(DEFAULT_BUF_SIZE);
}
public void setUnderlying(InStreamDesktop is, int bytesIn) {
this.underlying = is;
this.bytesIn = bytesIn;
this.ptr = this.end = 0;
}
public void reset() throws Exception {
ptr = end = 0;
if (underlying == null) return;
while (bytesIn > 0) {
decompress();
end = 0;
}
underlying = null;
}
public int pos() {
return ptrOffset + ptr;
}
@Override
protected int overrun(int itemSize, int nItems) throws Exception {
if (itemSize > bufSize) {
throw new Exception("ZlibInStream overrun: max itemSize exceeded");
}
if (underlying == null) {
throw new Exception("ZlibInStream overrun: no underlying stream");
}
if (end - ptr != 0) {
System.arraycopy(b, ptr, b, 0, end - ptr);
}
ptrOffset += ptr;
end -= ptr;
ptr = 0;
while (end < itemSize) {
decompress();
}
if (itemSize * nItems > end) {
nItems = end / itemSize;
}
return nItems;
}
private void decompress() throws Exception {
try {
underlying.check(1);
int availIn = underlying.getend() - underlying.getptr();
if (availIn > bytesIn) availIn = bytesIn;
if (inflater.needsInput()) {
inflater.setInput(underlying.getbuf(), underlying.getptr(), availIn);
}
int n = inflater.inflate(b, end, bufSize - end);
end += n;
if (inflater.needsInput()) {
bytesIn -= availIn;
underlying.setptr(underlying.getptr() + availIn);
}
} catch (DataFormatException e) {
throw new Exception("ZlibInStream inflate failed", e);
}
}
}
@@ -0,0 +1,221 @@
package com.litoralregas.backend.vnc.websocket;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.litoralregas.backend.vnc.rfb.VncClientDesktop;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.BinaryMessage;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.BinaryWebSocketHandler;
import java.nio.ByteBuffer;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component
public class VncStreamHandler extends BinaryWebSocketHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
private final Map<String, VncClientDesktop> clients = new ConcurrentHashMap<>();
@Override
protected void handleTextMessage(
WebSocketSession session,
TextMessage message
) {
try {
JsonNode json = objectMapper.readTree(message.getPayload());
String type = json.path("type").asText();
if ("connect".equals(type)) {
handleConnect(session, json);
return;
}
if ("click".equals(type)) {
handleClick(session, json);
return;
}
if ("disconnect".equals(type)) {
handleDisconnect(session);
return;
}
sendErrorSafe(session, "Unknown VNC message type: " + type);
} catch (Exception error) {
sendErrorSafe(session, error.getMessage());
}
}
private void handleConnect(
WebSocketSession session,
JsonNode json
) {
closeClient(session);
String host = json.path("host").asText();
int port = json.path("port").asInt(5900);
String password = json.path("password").asText();
if (host == null || host.isBlank()) {
sendErrorSafe(session, "Missing VNC host");
return;
}
sendStateSafe(session, "CONNECTING");
VncClientDesktop client = new VncClientDesktop();
client.setListener(createFrameListener(session));
clients.put(session.getId(), client);
try {
client.connect(host, port, password);
} catch (Exception error) {
clients.remove(session.getId());
sendErrorSafe(session, error.getMessage());
}
}
private void handleClick(
WebSocketSession session,
JsonNode json
) {
VncClientDesktop client = clients.get(session.getId());
if (client == null) {
sendErrorSafe(session, "No active VNC client for this websocket session");
return;
}
float x = (float) json.path("x").asDouble();
float y = (float) json.path("y").asDouble();
client.sendClick(x, y);
}
private void handleDisconnect(WebSocketSession session) {
closeClient(session);
sendStateSafe(session, "DISCONNECTED");
}
private VncClientDesktop.VncFrameListener createFrameListener(WebSocketSession session) {
return new VncClientDesktop.VncFrameListener() {
@Override
public void onConnected() {
sendStateSafe(session, "CONNECTED");
}
@Override
public void onFirstFrameReceived() {
sendStateSafe(session, "FIRST_FRAME");
}
@Override
public void onFrameUpdated(byte[] pixelBuffer, int width, int height) {
sendFrameSafe(session, pixelBuffer, width, height);
}
@Override
public void onError(String message) {
sendErrorSafe(session, message);
}
@Override
public void onDisconnected() {
sendStateSafe(session, "DISCONNECTED");
}
};
}
private void sendFrameSafe(
WebSocketSession session,
byte[] pixels,
int width,
int height
) {
try {
if (!session.isOpen()) {
closeClient(session);
return;
}
byte[] header = ByteBuffer.allocate(8)
.putInt(width)
.putInt(height)
.array();
ByteBuffer buffer = ByteBuffer.allocate(header.length + pixels.length);
buffer.put(header);
buffer.put(pixels);
buffer.flip();
synchronized (session) {
session.sendMessage(new BinaryMessage(buffer));
}
} catch (Exception error) {
closeClient(session);
}
}
private void sendStateSafe(
WebSocketSession session,
String state
) {
try {
if (!session.isOpen()) {
return;
}
synchronized (session) {
session.sendMessage(new TextMessage(
objectMapper.writeValueAsString(Map.of(
"type", "state",
"state", state
))
));
}
} catch (Exception ignored) {
}
}
private void sendErrorSafe(
WebSocketSession session,
String message
) {
try {
if (!session.isOpen()) {
return;
}
synchronized (session) {
session.sendMessage(new TextMessage(
objectMapper.writeValueAsString(Map.of(
"type", "error",
"message", message == null ? "Unknown VNC error" : message
))
));
}
} catch (Exception ignored) {
}
}
private void closeClient(WebSocketSession session) {
VncClientDesktop client = clients.remove(session.getId());
if (client != null) {
client.disconnect();
}
}
@Override
public void afterConnectionClosed(
WebSocketSession session,
CloseStatus status
) {
closeClient(session);
}
}
@@ -0,0 +1,23 @@
package com.litoralregas.backend.vnc.websocket;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class VncWebSocketConfig implements WebSocketConfigurer {
private final VncStreamHandler vncStreamHandler;
public VncWebSocketConfig(VncStreamHandler vncStreamHandler) {
this.vncStreamHandler = vncStreamHandler;
}
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(vncStreamHandler, "/ws/vnc")
.setAllowedOrigins("*");
}
}