diff --git a/src/main/java/com/litoralregas/backend/vnc/rfb/DesCipherDesktop.java b/src/main/java/com/litoralregas/backend/vnc/rfb/DesCipherDesktop.java new file mode 100644 index 0000000..25ec74c --- /dev/null +++ b/src/main/java/com/litoralregas/backend/vnc/rfb/DesCipherDesktop.java @@ -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; + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/vnc/rfb/DesktopBitmapData.java b/src/main/java/com/litoralregas/backend/vnc/rfb/DesktopBitmapData.java new file mode 100644 index 0000000..ba68da0 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/vnc/rfb/DesktopBitmapData.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/vnc/rfb/GreetClient.java b/src/main/java/com/litoralregas/backend/vnc/rfb/GreetClient.java new file mode 100644 index 0000000..7b53991 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/vnc/rfb/GreetClient.java @@ -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(); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/vnc/rfb/InStreamDesktop.java b/src/main/java/com/litoralregas/backend/vnc/rfb/InStreamDesktop.java new file mode 100644 index 0000000..8e4a901 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/vnc/rfb/InStreamDesktop.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/vnc/rfb/MemInStreamDesktop.java b/src/main/java/com/litoralregas/backend/vnc/rfb/MemInStreamDesktop.java new file mode 100644 index 0000000..6aee71f --- /dev/null +++ b/src/main/java/com/litoralregas/backend/vnc/rfb/MemInStreamDesktop.java @@ -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"); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/vnc/rfb/RfbProtoDesktop.java b/src/main/java/com/litoralregas/backend/vnc/rfb/RfbProtoDesktop.java new file mode 100644 index 0000000..27d9efd --- /dev/null +++ b/src/main/java/com/litoralregas/backend/vnc/rfb/RfbProtoDesktop.java @@ -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; + } + } +} diff --git a/src/main/java/com/litoralregas/backend/vnc/rfb/VncClientDesktop.java b/src/main/java/com/litoralregas/backend/vnc/rfb/VncClientDesktop.java new file mode 100644 index 0000000..b4080d0 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/vnc/rfb/VncClientDesktop.java @@ -0,0 +1,1771 @@ +package com.litoralregas.backend.vnc.rfb; + + +import java.io.IOException; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.zip.Inflater; +import java.io.ByteArrayInputStream; +import java.awt.image.BufferedImage; +import javax.imageio.ImageIO; + +public class VncClientDesktop { + + public interface VncFrameListener { + void onConnected(); + void onFirstFrameReceived(); + void onFrameUpdated(byte[] pixelBuffer, int width, int height); + void onError(String message); + void onDisconnected(); + } + + public interface ConnectionRecoveryListener { + void onVncReconnectFailedPermanently(); + void onVncMachineUnavailable(); + } + private volatile boolean forceNextFullRefresh = false; + private final AtomicBoolean running = new AtomicBoolean(false); + private final AtomicBoolean connected = new AtomicBoolean(false); + + // Prevents frame notifications from building a stale queue. + private final AtomicBoolean frameNotifyPending = new AtomicBoolean(false); + private final AtomicBoolean firstFrameNotified = new AtomicBoolean(false); + + private static final long MIN_FRAME_INTERVAL_MS = 20; + private volatile long lastFrameRequestTime = 0; + private volatile boolean useOldDesktopEncodings = true; + private volatile boolean useFallbackEncodings = false; + private volatile boolean triedFallbackEncodings = false; + private volatile boolean usedFallbackSuccessfully = false; + private volatile boolean gotAnyFramebufferUpdate = false; + private volatile boolean portDiedDuringFallback = false; + private volatile boolean startWithFallbackProfile = false; + private volatile boolean stoppingIntentionally = false; + private int zrleBytesPerPixel; + + /** + * Enable this only for bad udp2raw / high-latency / low-bandwidth links. + * Note: some VNC servers behave differently with 16-bit + ZRLE. If a controller fails, + * disable this or force fallback encodings. + */ + private volatile boolean lowBandwidthPixelFormat = false; + + private String host; + private int port; + private String password; + + private Thread workerThread; + + private VncFrameListener listener; + private ConnectionRecoveryListener recoveryListener; + + private RfbProtoDesktop rfb; + private DesktopBitmapData bitmapData; + + private int[] colorPalette = new int[256]; + + private int bytesPerPixel; + private int clientBitsPerPixel = 32; + private int clientDepth = 24; + private boolean clientBigEndian = false; + private int clientRedShift = 16; + private int clientGreenShift = 8; + private int clientBlueShift = 0; + private int clientRedMax = 255; + private int clientGreenMax = 255; + private int clientBlueMax = 255; + + private byte[] zrleBuf; + private int[] zrleTilePixels; + private final int[] zrlePalette = new int[128]; + private ZlibInStreamDesktop zrleInStream; + + private byte[] zlibBuf; + private Inflater zlibInflater; + + private byte[] bgBuf = new byte[4]; + private byte[] rreBuf = new byte[128]; + private byte[] rawRectBuffer = new byte[128]; + private byte[] zlibRectBuffer = new byte[128]; + private byte[] readPixelsBuffer = new byte[128]; + private byte[] backgroundColorBuffer = new byte[4]; + + // Reused bulk RGB buffers. Values are 0x00RRGGBB. + private int[] bulkPixels; + private int[] linePixels; + + // ------------------------------------------------------------------------- + // Tight encoding state + // ------------------------------------------------------------------------- + + /** + * Tight uses up to 4 independent zlib streams, identified by bits 0-3 of + * the compression-control byte. Each stream persists across rectangle + * boundaries; they are only reset when the server sets the corresponding + * reset bit in compCtl. + */ + + private byte[] tightJpegBuf = new byte[2048]; + private boolean imageIoConfigured = false; + private final Inflater[] tightInflaters = new Inflater[4]; + + /** Scratch buffer for compressed Tight data read from the network. */ + private byte[] tightZlibBuf = new byte[2048]; + + /** Scratch buffer for decompressed / raw Tight pixel data. */ + private byte[] tightBuf = new byte[2048]; + + // Tight compression-control sub-type constants + private static final int TIGHT_FILL = 0x08; + private static final int TIGHT_JPEG = 0x09; + + // Tight filter IDs (sent as an extra byte for Basic sub-type) + private static final int TIGHT_FILTER_COPY = 0x00; + private static final int TIGHT_FILTER_PALETTE = 0x01; + private static final int TIGHT_FILTER_GRADIENT = 0x02; + + /** + * Data smaller than this threshold is sent uncompressed by Tight even + * when a zlib stream is nominally active. + */ + private static final int TIGHT_MIN_TO_COMPRESS = 12; + + // ----------------------------LOGS--------------------------------------- + private long statLastLogTime = 0; + private long statFrames = 0; + private long statRects = 0; + private long statJpegRects = 0; + private long statTightRects = 0; + private long statZrleRects = 0; + private long statRawRects = 0; + private long statCopyRects = 0; + private long statCursorRects = 0; + private long statOtherRects = 0; + + // ------------------------------------------------------------------------- + + public void setListener(VncFrameListener listener) { + this.listener = listener; + } + + public void setRecoveryListener(ConnectionRecoveryListener listener) { + this.recoveryListener = listener; + } + + public void setStartWithFallbackProfile(boolean value) { + this.startWithFallbackProfile = value; + } + + public void setLowBandwidthPixelFormat(boolean value) { + this.lowBandwidthPixelFormat = value; + } + + public boolean wasFallbackTried() { + return triedFallbackEncodings; + } + + public boolean isUsingFallbackEncodings() { + return useFallbackEncodings; + } + + public boolean wasFallbackSuccessful() { + return usedFallbackSuccessfully; + } + + public boolean wasPortDiedDuringFallback() { + return portDiedDuringFallback; + } + + public void connect(String host, int port, String password) { + if (running.get()) { + throw new IllegalStateException("VNC client already running"); + } + configureImageIo(); + + this.host = host; + this.port = port; + this.password = password; + + this.useOldDesktopEncodings = !startWithFallbackProfile; + this.useFallbackEncodings = startWithFallbackProfile; + this.triedFallbackEncodings = startWithFallbackProfile; + this.usedFallbackSuccessfully = false; + this.gotAnyFramebufferUpdate = false; + this.portDiedDuringFallback = false; + this.stoppingIntentionally = false; + this.frameNotifyPending.set(false); + this.firstFrameNotified.set(false); + this.lastFrameRequestTime = 0; + this.forceNextFullRefresh = true; + + for (int i = 0; i < 4; i++) { + tightInflaters[i] = null; + } + + running.set(true); + + workerThread = new Thread(this::runClient, "vnc-desktop-worker"); + workerThread.setDaemon(true); + workerThread.start(); + } + + public void disconnect() { + stoppingIntentionally = true; + running.set(false); + frameNotifyPending.set(false); + + try { + if (rfb != null) { + rfb.close(); + } + } catch (Exception ignored) { + } + + connected.set(false); + } + + public void requestFullRefresh() { + try { + if (rfb != null && connected.get() && bitmapData != null) { + rfb.writeFramebufferUpdateRequest( + 0, + 0, + bitmapData.getFramebufferWidth(), + bitmapData.getFramebufferHeight(), + false + ); + } + } catch (Exception e) { + handleConnectionLostOrFailed(e); + } + } + + public void sendClick(float x, float y) { + try { + if (rfb == null || !connected.get() || bitmapData == null) { + return; + } + + int xi = Math.max(0, Math.min(bitmapData.getFramebufferWidth() - 1, Math.round(x))); + int yi = Math.max(0, Math.min(bitmapData.getFramebufferHeight() - 1, Math.round(y))); + + // Reset throttle so the next frame request goes out immediately + // after the click — no delay waiting for the interval. + lastFrameRequestTime = 0; + + rfb.writeClickAndFramebufferRequest( + xi, + yi, + bitmapData.getFramebufferWidth(), + bitmapData.getFramebufferHeight() + ); + } catch (Exception e) { + handleConnectionLostOrFailed(e); + } + } + + private void runClient() { + try { + connectAndAuthenticate(); + doProtocolInitialisation(); + + connected.set(true); + notifyConnected(); + + processNormalProtocol(); + + } catch (Throwable e) { + if (!stoppingIntentionally) { + handleConnectionLostOrFailed(e); + } + } finally { + running.set(false); + connected.set(false); + frameNotifyPending.set(false); + + try { + if (rfb != null) { + rfb.close(); + } + } catch (Exception ignored) { + } + + notifyDisconnected(); + } + } + + private void connectAndAuthenticate() throws Exception { + rfb = new RfbProtoDesktop(host, port); + + rfb.readVersionMsg(); + rfb.writeVersionMsg(); + + System.out.println( + "RFB version negotiated: 3." + + rfb.getClientMinor() + + " host=" + host + + " port=" + port + ); + + int secType = rfb.negotiateSecurity(); + + System.out.println("RFB security type: " + secType); + + if (secType == RfbProtoDesktop.SEC_TYPE_VNC_AUTH) { + rfb.authenticateVNC(password); + } else if (secType == RfbProtoDesktop.SEC_TYPE_NONE && rfb.getClientMinor() >= 8) { + rfb.readSecurityResult("No authentication"); + } + } + + private void doProtocolInitialisation() throws Exception { + rfb.writeClientInit(); + rfb.readServerInit(); + + System.out.println( + "RFB server init: framebuffer=" + + rfb.getFramebufferWidth() + + "x" + + rfb.getFramebufferHeight() + + " clientMinor=" + + rfb.getClientMinor() + ); + + bitmapData = new DesktopBitmapData( + rfb.getFramebufferWidth(), + rfb.getFramebufferHeight() + ); + + setPixelFormat(); + + System.out.println( + "Client pixel format: bpp=" + + clientBitsPerPixel + + " depth=" + clientDepth + + " bytesPerPixel=" + bytesPerPixel + + " zrleBytesPerPixel=" + zrleBytesPerPixel + + " endian=" + (clientBigEndian ? "big" : "little") + + " shifts R/G/B=" + + clientRedShift + "/" + + clientGreenShift + "/" + + clientBlueShift + ); + } + + private void setPixelFormat() throws Exception { + if (lowBandwidthPixelFormat) { + clientBitsPerPixel = 16; + clientDepth = 16; + clientBigEndian = false; + clientRedMax = 31; + clientGreenMax = 63; + clientBlueMax = 31; + clientRedShift = 11; + clientGreenShift = 5; + clientBlueShift = 0; + + rfb.writeSetPixelFormat( + 16, 16, false, true, + 31, 63, 31, + 11, 5, 0, + false + ); + + bytesPerPixel = 2; + zrleBytesPerPixel = 2; + return; + } + + clientBitsPerPixel = 32; + clientDepth = 24; + clientBigEndian = false; + clientRedMax = 255; + clientGreenMax = 255; + clientBlueMax = 255; + clientRedShift = 16; + clientGreenShift = 8; + clientBlueShift = 0; + + rfb.writeSetPixelFormat( + 32, 24, false, true, + 255, 255, 255, + 16, 8, 0, + false + ); + + bytesPerPixel = 4; + zrleBytesPerPixel = 3; + } + + private void setEncodings(boolean autoSelectOnly) throws Exception { + if (rfb == null || !rfb.isInNormalProtocol()) { + return; + } + + int[] encodings = new int[24]; + int n = 0; + + encodings[n++] = RfbProtoDesktop.ENCODING_COPY_RECT; + + // Safe real encodings. Do not advertise Tight here. + encodings[n++] = RfbProtoDesktop.ENCODING_ZRLE; + encodings[n++] = RfbProtoDesktop.ENCODING_ZLIB; + encodings[n++] = RfbProtoDesktop.ENCODING_HEXTILE; + encodings[n++] = RfbProtoDesktop.ENCODING_RRE; + encodings[n++] = RfbProtoDesktop.ENCODING_RAW; + + // Pseudo-encodings. + encodings[n++] = RfbProtoDesktop.ENCODING_RICH_CURSOR; + + if (rfb.getClientMinor() >= 8) { + encodings[n++] = RfbProtoDesktop.ENCODING_NEW_FB_SIZE; + } + + encodings[n++] = -239; // Some controllers need this pseudo-encoding to start/activate updates. + + encodings[n++] = RfbProtoDesktop.ENCODING_LAST_RECT; + + rfb.writeSetEncodings(encodings, n); + } + + private void processNormalProtocol() throws Exception { + setEncodings(false); + + rfb.writeFramebufferUpdateRequest( + 0, + 0, + bitmapData.getFramebufferWidth(), + bitmapData.getFramebufferHeight(), + false + ); + + lastFrameRequestTime = System.currentTimeMillis(); + while (running.get()) { + int msgType = rfb.readServerMessageType(); + + switch (msgType) { + case RfbProtoDesktop.FRAMEBUFFER_UPDATE: + gotAnyFramebufferUpdate = true; + if (useFallbackEncodings) { + usedFallbackSuccessfully = true; + } + + rfb.readFramebufferUpdate(); + + // Throttled early request — fire before decoding so the + // server can start preparing the next update while we + // decode this one. Capped at MIN_FRAME_INTERVAL_MS so we + // don't flood the router with redundant traffic. + + + for (int i = 0; i < rfb.getUpdateNRects(); i++) { + rfb.readFramebufferUpdateRectHdr(); + + int rx = rfb.getUpdateRectX(); + int ry = rfb.getUpdateRectY(); + int rw = rfb.getUpdateRectW(); + int rh = rfb.getUpdateRectH(); + int encoding = rfb.getUpdateRectEncoding(); + //System.out.println("Rect encoding = " + encoding); + statRects++; + + if (encoding == RfbProtoDesktop.ENCODING_TIGHT) statTightRects++; + else if (encoding == RfbProtoDesktop.ENCODING_ZRLE) statZrleRects++; + else if (encoding == RfbProtoDesktop.ENCODING_RAW) statRawRects++; + else if (encoding == RfbProtoDesktop.ENCODING_COPY_RECT) statCopyRects++; + else if (encoding == RfbProtoDesktop.ENCODING_X_CURSOR + || encoding == RfbProtoDesktop.ENCODING_RICH_CURSOR + || encoding == RfbProtoDesktop.ENCODING_POINTER_POS) statCursorRects++; + else statOtherRects++; + + if (encoding == RfbProtoDesktop.ENCODING_LAST_RECT) { + break; + } + + if (encoding == RfbProtoDesktop.ENCODING_NEW_FB_SIZE) { + bitmapData.resize(rfb.getFramebufferWidth(), rfb.getFramebufferHeight()); + forceNextFullRefresh = true; + continue; + } + + rx = Math.max(0, Math.min(rx, bitmapData.getFramebufferWidth())); + ry = Math.max(0, Math.min(ry, bitmapData.getFramebufferHeight())); + rw = Math.min(rw, bitmapData.getFramebufferWidth() - rx); + rh = Math.min(rh, bitmapData.getFramebufferHeight() - ry); + + if (rw <= 0 || rh <= 0) { + continue; + } + + if (encoding == RfbProtoDesktop.ENCODING_X_CURSOR + || encoding == RfbProtoDesktop.ENCODING_RICH_CURSOR) { + + int bytesPerRow = (rw + 7) / 8; + int dataLen = bytesPerRow * rh; + + if (encoding == RfbProtoDesktop.ENCODING_X_CURSOR) { + rfb.getInputStream().skipBytes(6 + 2 * dataLen); + } else { + rfb.getInputStream().skipBytes(bytesPerPixel * rw * rh + dataLen); + } + continue; + } + + if (encoding == RfbProtoDesktop.ENCODING_POINTER_POS) { + continue; + } + + switch (encoding) { + case RfbProtoDesktop.ENCODING_RAW: + handleRawRect(rx, ry, rw, rh); + break; + + case RfbProtoDesktop.ENCODING_RRE: + handleRRERect(rx, ry, rw, rh); + break; + + case RfbProtoDesktop.ENCODING_CORRE: + handleCoRRERect(rx, ry, rw, rh); + break; + + case RfbProtoDesktop.ENCODING_HEXTILE: + handleHextileRect(rx, ry, rw, rh); + break; + + case RfbProtoDesktop.ENCODING_ZLIB: + handleZlibRect(rx, ry, rw, rh); + break; + + case RfbProtoDesktop.ENCODING_ZRLE: + handleZRLERect(rx, ry, rw, rh); + break; + + case RfbProtoDesktop.ENCODING_TIGHT: + handleTightRect(rx, ry, rw, rh); + break; + + case RfbProtoDesktop.ENCODING_COPY_RECT: + handleCopyRect(rx, ry, rw, rh); + break; + + default: + throw new IOException("Unknown RFB rectangle encoding " + encoding); + } + } + + notifyFrameUpdated(); + notifyFirstFrameReceived(); + requestNextFramebufferUpdateThrottled(); + logVncStats(); + break; + + case RfbProtoDesktop.SET_COLOUR_MAP_ENTRIES: + readColorMap(); + break; + + case RfbProtoDesktop.BELL: + break; + + case 12: + throw new IOException("FramebufferUpdateExtended not supported"); + + default: + throw new IOException("Unknown RFB message type " + msgType); + } + } + } + + private void logVncStats() { + statFrames++; + + long now = System.currentTimeMillis(); + if (statLastLogTime == 0) { + statLastLogTime = now; + return; + } + + long elapsed = now - statLastLogTime; + if (elapsed < 5000) { + return; + } + + double fps = statFrames * 1000.0 / elapsed; + + /*System.out.println( + "VNC stats: fps=" + String.format("%.1f", fps) + + " rects=" + statRects + + " tight=" + statTightRects + + " jpeg=" + statJpegRects + + " zrle=" + statZrleRects + + " raw=" + statRawRects + + " copy=" + statCopyRects + + " cursor=" + statCursorRects + + " other=" + statOtherRects + );*/ + + statLastLogTime = now; + statFrames = 0; + statRects = 0; + statJpegRects = 0; + statTightRects = 0; + statZrleRects = 0; + statRawRects = 0; + statCopyRects = 0; + statCursorRects = 0; + statOtherRects = 0; + } + private void requestNextFramebufferUpdateThrottled() throws Exception { + long now = System.currentTimeMillis(); + long elapsed = now - lastFrameRequestTime; + + if (elapsed < MIN_FRAME_INTERVAL_MS) { + try { + Thread.sleep(MIN_FRAME_INTERVAL_MS - elapsed); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + } + + lastFrameRequestTime = System.currentTimeMillis(); + + boolean incremental = !forceNextFullRefresh; + forceNextFullRefresh = false; + + rfb.writeFramebufferUpdateRequest( + 0, + 0, + bitmapData.getFramebufferWidth(), + bitmapData.getFramebufferHeight(), + incremental + ); + } + private void readColorMap() throws Exception { + rfb.getInputStream().readUnsignedByte(); + int firstColor = rfb.getInputStream().readUnsignedShort(); + int nColors = rfb.getInputStream().readUnsignedShort(); + + if (colorPalette == null || colorPalette.length < firstColor + nColors) { + colorPalette = Arrays.copyOf(colorPalette, firstColor + nColors); + } + + for (int i = 0; i < nColors; i++) { + int r = rfb.getInputStream().readUnsignedShort(); + int g = rfb.getInputStream().readUnsignedShort(); + int b = rfb.getInputStream().readUnsignedShort(); + colorPalette[firstColor + i] = + ((r >> 8) << 16) | ((g >> 8) << 8) | (b >> 8); + } + } + + private int decodePixel(byte[] buf, int idx) { + if (bytesPerPixel == 1) { + return colorPalette[buf[idx] & 0xFF]; + } + + int raw = 0; + + if (clientBigEndian) { + for (int i = 0; i < bytesPerPixel; i++) { + raw = (raw << 8) | (buf[idx + i] & 0xFF); + } + } else { + for (int i = bytesPerPixel - 1; i >= 0; i--) { + raw = (raw << 8) | (buf[idx + i] & 0xFF); + } + } + + return expandRawPixel(raw); + } + + private int expandRawPixel(int raw) { + int r = (raw >> clientRedShift) & clientRedMax; + int g = (raw >> clientGreenShift) & clientGreenMax; + int b = (raw >> clientBlueShift) & clientBlueMax; + + if (clientRedMax != 255) { + r = (r * 255 + clientRedMax / 2) / clientRedMax; + } + if (clientGreenMax != 255) { + g = (g * 255 + clientGreenMax / 2) / clientGreenMax; + } + if (clientBlueMax != 255) { + b = (b * 255 + clientBlueMax / 2) / clientBlueMax; + } + + return (r << 16) | (g << 8) | b; + } + + private void ensureBulkPixels(int count) { + if (bulkPixels == null || bulkPixels.length < count) { + bulkPixels = new int[count]; + } + } + + private void ensureLinePixels(int count) { + if (linePixels == null || linePixels.length < count) { + linePixels = new int[count]; + } + } + + // ========================================================================= + // Existing encoding handlers (unchanged) + // ========================================================================= + + private void handleCopyRect(int x, int y, int w, int h) throws Exception { + rfb.readCopyRect(); + bitmapData.copyRect(rfb.getCopyRectSrcX(), rfb.getCopyRectSrcY(), x, y, w, h); + } + + private void handleRRERect(int x, int y, int w, int h) throws Exception { + boolean valid = bitmapData.validDraw(x, y, w, h); + int nSubrects = rfb.getInputStream().readInt(); + + rfb.readFully(bgBuf, 0, bytesPerPixel); + + int pixel = (bytesPerPixel == 1) + ? colorPalette[0xFF & bgBuf[0]] + : decodePixel(bgBuf, 0); + + if (valid) { + bitmapData.fillRect(x, y, w, h, pixel); + } + + int len = nSubrects * (bytesPerPixel + 8); + if (len > rreBuf.length) { + rreBuf = new byte[len]; + } + + rfb.readFully(rreBuf, 0, len); + if (!valid) { + return; + } + + int i = 0; + for (int j = 0; j < nSubrects; j++) { + if (bytesPerPixel == 1) { + pixel = colorPalette[0xFF & rreBuf[i++]]; + } else { + pixel = decodePixel(rreBuf, i); + i += bytesPerPixel; + } + + int sx = x + ((rreBuf[i] & 0xff) << 8) + (rreBuf[i + 1] & 0xff); + i += 2; + int sy = y + ((rreBuf[i] & 0xff) << 8) + (rreBuf[i + 1] & 0xff); + i += 2; + int sw = ((rreBuf[i] & 0xff) << 8) + (rreBuf[i + 1] & 0xff); + i += 2; + int sh = ((rreBuf[i] & 0xff) << 8) + (rreBuf[i + 1] & 0xff); + i += 2; + + bitmapData.fillRect(sx, sy, sw, sh, pixel); + } + } + + private void handleCoRRERect(int x, int y, int w, int h) throws Exception { + boolean valid = bitmapData.validDraw(x, y, w, h); + int nSubrects = rfb.getInputStream().readInt(); + + rfb.readFully(bgBuf, 0, bytesPerPixel); + + int pixel = (bytesPerPixel == 1) + ? colorPalette[0xFF & bgBuf[0]] + : decodePixel(bgBuf, 0); + + if (valid) { + bitmapData.fillRect(x, y, w, h, pixel); + } + + int len = nSubrects * (bytesPerPixel + 4); + if (len > rreBuf.length) { + rreBuf = new byte[len]; + } + + rfb.readFully(rreBuf, 0, len); + if (!valid) { + return; + } + + int i = 0; + for (int j = 0; j < nSubrects; j++) { + if (bytesPerPixel == 1) { + pixel = colorPalette[0xFF & rreBuf[i++]]; + } else { + pixel = decodePixel(rreBuf, i); + i += bytesPerPixel; + } + + int sx = x + (rreBuf[i++] & 0xFF); + int sy = y + (rreBuf[i++] & 0xFF); + int sw = rreBuf[i++] & 0xFF; + int sh = rreBuf[i++] & 0xFF; + + bitmapData.fillRect(sx, sy, sw, sh, pixel); + } + } + + private int hextileBg = 0; + private int hextileFg = 0; + + private void handleHextileRect(int x, int y, int w, int h) throws Exception { + hextileBg = 0; + hextileFg = 0; + + for (int ty = y; ty < y + h; ty += 16) { + int th = Math.min(y + h - ty, 16); + + for (int tx = x; tx < x + w; tx += 16) { + int tw = Math.min(x + w - tx, 16); + handleHextileSubrect(tx, ty, tw, th); + } + } + } + + private void handleHextileSubrect(int tx, int ty, int tw, int th) throws Exception { + int subencoding = rfb.getInputStream().readUnsignedByte(); + + if ((subencoding & RfbProtoDesktop.HEXTILE_RAW) != 0) { + handleRawRect(tx, ty, tw, th); + return; + } + + boolean valid = bitmapData.validDraw(tx, ty, tw, th); + + if ((subencoding & RfbProtoDesktop.HEXTILE_BACKGROUND_SPECIFIED) != 0) { + rfb.readFully(backgroundColorBuffer, 0, bytesPerPixel); + hextileBg = (bytesPerPixel == 1) + ? colorPalette[0xFF & backgroundColorBuffer[0]] + : decodePixel(backgroundColorBuffer, 0); + } + + if (valid) { + bitmapData.fillRect(tx, ty, tw, th, hextileBg); + } + + if ((subencoding & RfbProtoDesktop.HEXTILE_FOREGROUND_SPECIFIED) != 0) { + rfb.readFully(backgroundColorBuffer, 0, bytesPerPixel); + hextileFg = (bytesPerPixel == 1) + ? colorPalette[0xFF & backgroundColorBuffer[0]] + : decodePixel(backgroundColorBuffer, 0); + } + + if ((subencoding & RfbProtoDesktop.HEXTILE_ANY_SUBRECTS) == 0) { + return; + } + + int nSubrects = rfb.getInputStream().readUnsignedByte(); + int bufsize = nSubrects * 2; + + if ((subencoding & RfbProtoDesktop.HEXTILE_SUBRECTS_COLOURED) != 0) { + bufsize += nSubrects * bytesPerPixel; + } + + if (rreBuf.length < bufsize) { + rreBuf = new byte[bufsize]; + } + + rfb.readFully(rreBuf, 0, bufsize); + + int i = 0; + + if ((subencoding & RfbProtoDesktop.HEXTILE_SUBRECTS_COLOURED) == 0) { + for (int j = 0; j < nSubrects; j++) { + int b1 = rreBuf[i++] & 0xFF; + int b2 = rreBuf[i++] & 0xFF; + int sx = tx + (b1 >> 4); + int sy = ty + (b1 & 0xf); + int sw = (b2 >> 4) + 1; + int sh = (b2 & 0xf) + 1; + + if (valid) { + bitmapData.fillRect(sx, sy, sw, sh, hextileFg); + } + } + } else if (bytesPerPixel == 1) { + for (int j = 0; j < nSubrects; j++) { + int color = colorPalette[0xFF & rreBuf[i++]]; + int b1 = rreBuf[i++] & 0xFF; + int b2 = rreBuf[i++] & 0xFF; + int sx = tx + (b1 >> 4); + int sy = ty + (b1 & 0xf); + int sw = (b2 >> 4) + 1; + int sh = (b2 & 0xf) + 1; + + if (valid) { + bitmapData.fillRect(sx, sy, sw, sh, color); + } + } + } else { + for (int j = 0; j < nSubrects; j++) { + int color = decodePixel(rreBuf, i); + i += bytesPerPixel; + int b1 = rreBuf[i++] & 0xFF; + int b2 = rreBuf[i++] & 0xFF; + int sx = tx + (b1 >> 4); + int sy = ty + (b1 & 0xf); + int sw = (b2 >> 4) + 1; + int sh = (b2 & 0xf) + 1; + + if (valid) { + bitmapData.fillRect(sx, sy, sw, sh, color); + } + } + } + } + + private void handleZlibRect(int x, int y, int w, int h) throws Exception { + boolean valid = bitmapData.validDraw(x, y, w, h); + int nBytes = rfb.getInputStream().readInt(); + + if (zlibBuf == null || zlibBuf.length < nBytes) { + zlibBuf = new byte[nBytes]; + } + + rfb.readFully(zlibBuf, 0, nBytes); + + if (zlibInflater == null) { + zlibInflater = new Inflater(); + } + + zlibInflater.reset(); + zlibInflater.setInput(zlibBuf, 0, nBytes); + + int expectedBytes = w * h * bytesPerPixel; + if (expectedBytes > zlibRectBuffer.length) { + zlibRectBuffer = new byte[expectedBytes]; + } + + int inflated = 0; + while (inflated < expectedBytes) { + int n = zlibInflater.inflate(zlibRectBuffer, inflated, expectedBytes - inflated); + if (n <= 0) { + if (zlibInflater.finished() || zlibInflater.needsInput()) { + break; + } + throw new IOException("ZLIB inflate stalled"); + } + inflated += n; + } + + if (!valid) { + return; + } + + ensureBulkPixels(w * h); + int src = 0; + int dst = 0; + + if (bytesPerPixel == 1) { + for (int i = 0; i < w * h; i++) { + bulkPixels[dst++] = colorPalette[zlibRectBuffer[src++] & 0xFF]; + } + } else { + for (int i = 0; i < w * h; i++) { + bulkPixels[dst++] = decodePixel(zlibRectBuffer, src); + src += bytesPerPixel; + } + } + + bitmapData.setRgbPixels(x, y, w, h, bulkPixels, 0, w); + } + + // ========================================================================= + // ZRLE handlers (unchanged) + // ========================================================================= + + private int readZrlePixel(InStreamDesktop is) throws Exception { + if (zrleBytesPerPixel == 1) { + return is.readU8(); + } + + if (zrleBytesPerPixel == 3) { + int p1 = is.readU8(); + int p2 = is.readU8(); + int p3 = is.readU8(); + return ((p3 & 0xFF) << 16) | ((p2 & 0xFF) << 8) | (p1 & 0xFF); + } + + int raw = 0; + if (clientBigEndian) { + for (int i = 0; i < zrleBytesPerPixel; i++) { + raw = (raw << 8) | is.readU8(); + } + } else { + for (int i = 0; i < zrleBytesPerPixel; i++) { + raw |= is.readU8() << (8 * i); + } + } + return expandRawPixel(raw); + } + + private void readZrlePixels(InStreamDesktop is, int[] dst, int count) throws Exception { + if (zrleBytesPerPixel == 1) { + if (count > readPixelsBuffer.length) { + readPixelsBuffer = new byte[count]; + } + is.readBytes(readPixelsBuffer, 0, count); + for (int i = 0; i < count; i++) { + dst[i] = readPixelsBuffer[i] & 0xFF; + } + return; + } + + int len = count * zrleBytesPerPixel; + if (len > readPixelsBuffer.length) { + readPixelsBuffer = new byte[len]; + } + + is.readBytes(readPixelsBuffer, 0, len); + + int p = 0; + for (int i = 0; i < count; i++) { + if (zrleBytesPerPixel == 3) { + dst[i] = + ((readPixelsBuffer[p + 2] & 0xFF) << 16) + | ((readPixelsBuffer[p + 1] & 0xFF) << 8) + | (readPixelsBuffer[p] & 0xFF); + p += 3; + } else { + dst[i] = decodePixel(readPixelsBuffer, p); + p += zrleBytesPerPixel; + } + } + } + + private void readZrlePalette(int[] palette, int palSize) throws Exception { + readZrlePixels(zrleInStream, palette, palSize); + } + + private void readZrleRawPixels(int tw, int th) throws Exception { + int len = tw * th; + if (zrleTilePixels == null || len > zrleTilePixels.length) { + zrleTilePixels = new int[len]; + } + readZrlePixels(zrleInStream, zrleTilePixels, len); + } + + private void readZrlePackedPixels(int tw, int th, int[] palette, int palSize) throws Exception { + int bppp = (palSize > 16) ? 8 : ((palSize > 4) ? 4 : ((palSize > 2) ? 2 : 1)); + int ptr = 0; + int len = tw * th; + + if (zrleTilePixels == null || len > zrleTilePixels.length) { + zrleTilePixels = new int[len]; + } + + for (int row = 0; row < th; row++) { + int eol = ptr + tw; + int b = 0; + int nbits = 0; + + while (ptr < eol) { + if (nbits == 0) { + b = zrleInStream.readU8(); + nbits = 8; + } + + nbits -= bppp; + int index = (b >> nbits) & ((1 << bppp) - 1) & 127; + zrleTilePixels[ptr++] = (bytesPerPixel == 1) + ? colorPalette[0xFF & palette[index]] + : palette[index]; + } + } + } + + private void readZrlePlainRLEPixels(int tw, int th) throws Exception { + int ptr = 0; + int end = tw * th; + + if (zrleTilePixels == null || end > zrleTilePixels.length) { + zrleTilePixels = new int[end]; + } + + while (ptr < end) { + int pix = readZrlePixel(zrleInStream); + int len = 1; + int b; + + do { + b = zrleInStream.readU8(); + len += b; + } while (b == 255); + + if (bytesPerPixel == 1) { + pix = colorPalette[0xFF & pix]; + } + + int stop = Math.min(end, ptr + len); + Arrays.fill(zrleTilePixels, ptr, stop, pix); + ptr = stop; + } + } + + private void readZrlePackedRLEPixels(int tw, int th, int[] palette) throws Exception { + int ptr = 0; + int end = tw * th; + + if (zrleTilePixels == null || end > zrleTilePixels.length) { + zrleTilePixels = new int[end]; + } + + while (ptr < end) { + int index = zrleInStream.readU8(); + int len = 1; + + if ((index & 128) != 0) { + int b; + do { + b = zrleInStream.readU8(); + len += b; + } while (b == 255); + } + + index &= 127; + int pix = palette[index]; + if (bytesPerPixel == 1) { + pix = colorPalette[0xFF & pix]; + } + + int stop = Math.min(end, ptr + len); + Arrays.fill(zrleTilePixels, ptr, stop, pix); + ptr = stop; + } + } + + private void handleUpdatedZrleTile(int x, int y, int w, int h) { + bitmapData.setRgbPixels(x, y, w, h, zrleTilePixels, 0, w); + } + + private void handleZRLERect(int x, int y, int w, int h) throws Exception { + if (zrleInStream == null) { + zrleInStream = new ZlibInStreamDesktop(); + } + + int nBytes = rfb.getInputStream().readInt(); + + /*System.out.println( + "ZRLE rect START: x=" + x + + " y=" + y + + " w=" + w + + " h=" + h + + " compressedBytes=" + nBytes + + " zrleBpp=" + zrleBytesPerPixel + + " normalBpp=" + bytesPerPixel + );*/ + + if (nBytes > 64 * 1024 * 1024) { + throw new Exception("ZRLE decoder: illegal compressed data size " + nBytes); + } + + if (zrleBuf == null || zrleBuf.length < nBytes) { + zrleBuf = new byte[nBytes + 4096]; + } + + rfb.readFully(zrleBuf, 0, nBytes); + zrleInStream.setUnderlying(new MemInStreamDesktop(zrleBuf, 0, nBytes), nBytes); + + boolean rectValid = bitmapData.validDraw(x, y, w, h); + + int tileIndex = 0; + + try { + for (int ty = y; ty < y + h; ty += 64) { + int th = Math.min(y + h - ty, 64); + + for (int tx = x; tx < x + w; tx += 64) { + int tw = Math.min(x + w - tx, 64); + + int mode = zrleInStream.readU8(); + boolean rle = (mode & 128) != 0; + int palSize = mode & 127; + + /*System.out.println( + "ZRLE tile: index=" + tileIndex + + " tx=" + tx + + " ty=" + ty + + " tw=" + tw + + " th=" + th + + " mode=" + mode + + " rle=" + rle + + " palSize=" + palSize + );*/ + + if (palSize > zrlePalette.length) { + throw new IOException( + "ZRLE invalid palette size: " + palSize + + " at tileIndex=" + tileIndex + + " tx=" + tx + + " ty=" + ty + ); + } + + readZrlePalette(zrlePalette, palSize); + + if (palSize == 1) { + int pix = zrlePalette[0]; + int c = (bytesPerPixel == 1) + ? colorPalette[0xFF & pix] + : pix; + + if (rectValid) { + bitmapData.fillRect(tx, ty, tw, th, c); + } + + tileIndex++; + continue; + } + + if (!rle) { + if (palSize == 0) { + readZrleRawPixels(tw, th); + } else { + readZrlePackedPixels(tw, th, zrlePalette, palSize); + } + } else { + if (palSize == 0) { + readZrlePlainRLEPixels(tw, th); + } else { + readZrlePackedRLEPixels(tw, th, zrlePalette); + } + } + + if (rectValid) { + handleUpdatedZrleTile(tx, ty, tw, th); + } + + tileIndex++; + } + } + + if (rectValid) { + bitmapData.markSingleDirtyRect(x, y, w, h); + } + + /*System.out.println( + "ZRLE rect END OK: x=" + x + + " y=" + y + + " w=" + w + + " h=" + h + + " tiles=" + tileIndex + );*/ + + } catch (Exception e) { + /*System.out.println( + "ZRLE rect FAILED: x=" + x + + " y=" + y + + " w=" + w + + " h=" + h + + " compressedBytes=" + nBytes + + " failedTileIndex=" + tileIndex + + " error=" + e.getClass().getSimpleName() + + ": " + e.getMessage() + );*/ + throw e; + } + } + + // ========================================================================= + // Tight encoding handlers + // ========================================================================= + + /** + * Entry point for a Tight-encoded rectangle. + * + * Tight compression-control byte layout (RFB 3.8 spec, §6.6): + * + * Bits 0-3 — reset flags for zlib streams 0-3. + * If bit N is set the server has reset stream N; we must + * create a fresh Inflater for it. + * Bits 4-7 — compression type: + * 0x08 → Fill (solid colour) + * 0x09 → JPEG + * 0x00-0x07 → Basic (zlib stream index = bits 4-5, i.e. type & 0x03) + * Bit 6 (0x04) of the type nibble means a filter byte follows. + * In practice almost all servers always send the filter byte + * for Basic, so we read it unconditionally — this is safe. + * + */ + + private void configureImageIo() { + if (!imageIoConfigured) { + ImageIO.setUseCache(false); // avoids disk cache/temp files + ImageIO.scanForPlugins(); // makes sure TwelveMonkeys is registered + imageIoConfigured = true; + } + } + + private void handleTightRect(int x, int y, int w, int h) throws Exception { + boolean valid = bitmapData.validDraw(x, y, w, h); + + int compCtl = rfb.getInputStream().readUnsignedByte(); + + // Reset any zlib streams the server flagged. + for (int i = 0; i < 4; i++) { + if ((compCtl & (1 << i)) != 0) { + tightInflaters[i] = null; // will be (re)created on first use + } + } + + int type = (compCtl >> 4) & 0x0F; + + switch (type) { + case TIGHT_FILL: + handleTightFill(x, y, w, h, valid); + return; + + case TIGHT_JPEG: + handleTightJpeg(x, y, w, h, valid); + return; + + default: + // Basic: stream index is in the lower 2 bits of the type nibble. + // (Bits 4-5 of compCtl, i.e. bits 0-1 of the type nibble.) + handleTightBasic(x, y, w, h, valid, type & 0x03, (type & 0x04) != 0); + } + } + + /** + * Tight Fill sub-type: the entire rectangle is a single solid colour. + * The colour is always sent as 3 bytes of packed RGB regardless of the + * negotiated pixel format. + */ + private void handleTightFill(int x, int y, int w, int h, boolean valid) throws Exception { + // Always 3 bytes: R, G, B — Tight ignores the negotiated pixel format. + rfb.readFully(tightBuf, 0, 3); + if (!valid) return; + + int pixel = ((tightBuf[0] & 0xFF) << 16) + | ((tightBuf[1] & 0xFF) << 8) + | (tightBuf[2] & 0xFF); + bitmapData.fillRect(x, y, w, h, pixel); + } + + /** + * Tight JPEG sub-type: the rectangle is a JPEG image. + * Length is encoded as a compact 1-3 byte value (see readTightCompactLength). + * javax.imageio is used to decode — no extra dependency needed in a standard JDK. + */ + private void handleTightJpeg(int x, int y, int w, int h, boolean valid) throws Exception { + int dataLen = readTightCompactLength(); + statJpegRects++; + if (dataLen < 0 || dataLen > 64 * 1024 * 1024) { + throw new IOException("Tight JPEG: implausible data length " + dataLen); + } + + if (tightJpegBuf == null || tightJpegBuf.length < dataLen) { + tightJpegBuf = new byte[dataLen]; + } + + rfb.readFully(tightJpegBuf, 0, dataLen); + + if (!valid) { + return; + } + + BufferedImage img = ImageIO.read(new ByteArrayInputStream(tightJpegBuf, 0, dataLen)); + + if (img == null) { + throw new IOException("Tight JPEG: failed to decode JPEG data"); + } + + if (img.getWidth() < w || img.getHeight() < h) { + throw new IOException( + "Tight JPEG: decoded image too small. Expected " + + w + "x" + h + + ", got " + + img.getWidth() + "x" + img.getHeight() + ); + } + + ensureBulkPixels(w * h); + + img.getRGB(0, 0, w, h, bulkPixels, 0, w); + + for (int i = 0; i < w * h; i++) { + bulkPixels[i] &= 0x00FFFFFF; + } + + bitmapData.setRgbPixels(x, y, w, h, bulkPixels, 0, w); + } + + /** + * Tight Basic sub-type: pixels are compressed with one of 4 persistent + * zlib streams, optionally with a filter (palette or gradient). + * + * @param streamId zlib stream index 0-3, taken from compCtl bits 4-5. + */ + private void handleTightBasic(int x, int y, int w, int h, + boolean valid, int streamId, boolean filterPresent) throws Exception { + // The filter byte is present whenever bit 6 of compCtl is set (type & 0x04). + // In practice every server always sends it for Basic, so read it unconditionally. + int filterType = filterPresent + ? rfb.getInputStream().readUnsignedByte() + : TIGHT_FILTER_COPY; + + int[] palette = null; + int paletteSize = 0; + int rowSize; + + if (filterType == TIGHT_FILTER_PALETTE) { + // Number of palette entries is encoded as (byte value + 1), so 0 → 1, 255 → 256. + paletteSize = rfb.getInputStream().readUnsignedByte() + 1; + palette = new int[paletteSize]; + + // Palette entries are always packed 3-byte RGB. + int palBufLen = paletteSize * 3; + if (palBufLen > tightBuf.length) { + tightBuf = new byte[palBufLen]; + } + rfb.readFully(tightBuf, 0, palBufLen); + + for (int i = 0; i < paletteSize; i++) { + palette[i] = ((tightBuf[i * 3] & 0xFF) << 16) + | ((tightBuf[i * 3 + 1] & 0xFF) << 8) + | (tightBuf[i * 3 + 2] & 0xFF); + } + + // 2-colour palette → 1 bit per pixel, packed MSB-first per row. + // N-colour palette → 1 byte per pixel (index). + rowSize = (paletteSize <= 2) ? (w + 7) / 8 : w; + + } else { + // COPY (raw 24-bit RGB) or GRADIENT (delta-encoded 24-bit RGB). + rowSize = w * 3; + } + + int dataSize = rowSize * h; + byte[] data; + + if (dataSize < TIGHT_MIN_TO_COMPRESS) { + // Small rects are sent uncompressed regardless of the stream setting. + if (dataSize > tightBuf.length) { + tightBuf = new byte[dataSize]; + } + rfb.readFully(tightBuf, 0, dataSize); + data = tightBuf; + } else { + // Read the compressed payload. + int compLen = readTightCompactLength(); + if (compLen < 0 || compLen > 64 * 1024 * 1024) { + throw new IOException("Tight Basic: implausible compressed length " + compLen); + } + + if (compLen > tightZlibBuf.length) { + tightZlibBuf = new byte[compLen]; + } + rfb.readFully(tightZlibBuf, 0, compLen); + + // Lazy-create the stream; do NOT reset between calls — Tight streams + // are persistent across multiple rectangles within the same session. + if (tightInflaters[streamId] == null) { + tightInflaters[streamId] = new Inflater(); + } + Inflater inf = tightInflaters[streamId]; + inf.setInput(tightZlibBuf, 0, compLen); + + if (dataSize > tightBuf.length) { + tightBuf = new byte[dataSize]; + } + data = tightBuf; + + int inflated = 0; + while (inflated < dataSize) { + int n = inf.inflate(data, inflated, dataSize - inflated); + if (n <= 0) { + if (inf.finished() || inf.needsInput()) break; + throw new IOException("Tight zlib inflate stalled (stream " + streamId + ")"); + } + inflated += n; + } + } + + if (!valid) return; + + ensureBulkPixels(w * h); + + if (filterType == TIGHT_FILTER_PALETTE) { + if (paletteSize <= 2) { + // 1 bit per pixel, MSB first, rows are byte-aligned. + int dst = 0; + for (int row = 0; row < h; row++) { + int rowBase = row * rowSize; + for (int col = 0; col < w; col++) { + int bit = (data[rowBase + col / 8] >> (7 - col % 8)) & 1; + bulkPixels[dst++] = palette[bit]; + } + } + } else { + // 1 byte per pixel: direct palette index. + for (int i = 0; i < w * h; i++) { + bulkPixels[i] = palette[data[i] & 0xFF]; + } + } + + } else if (filterType == TIGHT_FILTER_GRADIENT) { + decodeTightGradient(data, w, h); + + } else { + // COPY filter: tightly-packed 24-bit RGB, row-major. + for (int i = 0; i < w * h; i++) { + bulkPixels[i] = ((data[i * 3] & 0xFF) << 16) + | ((data[i * 3 + 1] & 0xFF) << 8) + | (data[i * 3 + 2] & 0xFF); + } + } + + bitmapData.setRgbPixels(x, y, w, h, bulkPixels, 0, w); + bitmapData.markFullDirty(); + } + + /** + * Tight Gradient filter decoder. + * + * Each channel value in the stream is a delta relative to a predictor + * computed from the pixel above and the pixel to the left (minus the + * pixel diagonally above-left). The first row and first column are + * relative to 0. + * + * Decoded values are written directly into {@link #bulkPixels}. + */ + private void decodeTightGradient(byte[] data, int w, int h) { + // prevRow stores the fully-reconstructed channel values of the row above. + // Indexed as prevRow[col * 3 + channel]. + int[] prevRow = new int[w * 3]; + int[] curRow = new int[w * 3]; + + int dst = 0; + for (int row = 0; row < h; row++) { + int src = row * w * 3; + + for (int col = 0; col < w; col++) { + for (int c = 0; c < 3; c++) { + int idx = col * 3 + c; + int above = prevRow[idx]; + int left = (col > 0) ? curRow[(col - 1) * 3 + c] : 0; + int aboveLeft = (col > 0) ? prevRow[(col - 1) * 3 + c] : 0; + + // Predictor: clamp(above + left - aboveLeft) into [0, 255]. + int pred = Math.max(0, Math.min(255, above + left - aboveLeft)); + // Add the delta (treat byte as unsigned, wrap modulo 256). + curRow[idx] = (pred + (data[src + idx] & 0xFF)) & 0xFF; + } + } + + // Pack the current row into bulkPixels. + for (int col = 0; col < w; col++) { + bulkPixels[dst++] = (curRow[col * 3] << 16) + | (curRow[col * 3 + 1] << 8) + | curRow[col * 3 + 2]; + } + + // Swap rows without allocating: copy curRow into prevRow. + int[] tmp = prevRow; + prevRow = curRow; + curRow = tmp; + Arrays.fill(curRow, 0); + } + } + + /** + * Read a Tight compact length value. + * + * The format uses 1, 2, or 3 bytes. In each byte, bit 7 is a + * continuation flag and bits 0-6 carry data. The first byte holds + * bits 0-6, the second holds bits 7-13, and the third holds bits 14-20. + * All values are little-endian. + */ + private int readTightCompactLength() throws Exception { + int b0 = rfb.getInputStream().readUnsignedByte(); + int result = b0 & 0x7F; + + if ((b0 & 0x80) != 0) { + int b1 = rfb.getInputStream().readUnsignedByte(); + result |= (b1 & 0x7F) << 7; + + if ((b1 & 0x80) != 0) { + int b2 = rfb.getInputStream().readUnsignedByte(); + result |= (b2 & 0x7F) << 14; + } + } + + return result; + } + + // ========================================================================= + // Raw rect handler (unchanged) + // ========================================================================= + + private void handleRawRect(int x, int y, int w, int h) throws Exception { + boolean valid = bitmapData.validDraw(x, y, w, h); + int lineSize = w * bytesPerPixel; + + if (lineSize > rawRectBuffer.length) { + rawRectBuffer = new byte[lineSize]; + } + + if (!valid) { + for (int dy = 0; dy < h; dy++) { + rfb.readFully(rawRectBuffer, 0, lineSize); + } + return; + } + + ensureLinePixels(w); + + for (int dy = y; dy < y + h; dy++) { + rfb.readFully(rawRectBuffer, 0, lineSize); + + if (bytesPerPixel == 1) { + for (int i = 0; i < w; i++) { + linePixels[i] = colorPalette[rawRectBuffer[i] & 0xFF]; + } + } else { + int src = 0; + for (int i = 0; i < w; i++) { + linePixels[i] = decodePixel(rawRectBuffer, src); + src += bytesPerPixel; + } + } + + bitmapData.setRgbPixels(x, dy, w, 1, linePixels, 0, w); + } + } + + // ========================================================================= + // Connection error / recovery + // ========================================================================= + + private void handleConnectionLostOrFailed(Throwable e) { + running.set(false); + connected.set(false); + frameNotifyPending.set(false); + + boolean isConnectionRefused = + e instanceof java.net.ConnectException + || (e.getMessage() != null && e.getMessage().contains("ECONNREFUSED")); + + boolean failedBeforeFirstFramebuffer = !gotAnyFramebufferUpdate; + + if (isConnectionRefused && failedBeforeFirstFramebuffer) { + if (triedFallbackEncodings && !usedFallbackSuccessfully) { + portDiedDuringFallback = true; + } + + if (recoveryListener != null) { + recoveryListener.onVncMachineUnavailable(); + } else { + notifyError("Machine unavailable"); + } + return; + } + + boolean looksLikeEncodingMismatch = + !gotAnyFramebufferUpdate + && !useFallbackEncodings + && !triedFallbackEncodings + && (e instanceof IOException || e instanceof java.net.SocketException); + + if (looksLikeEncodingMismatch) { + triedFallbackEncodings = true; + useFallbackEncodings = true; + useOldDesktopEncodings = false; + + try { + if (rfb != null) { + rfb.close(); + } + } catch (Exception ignored) { + } + + new Thread(() -> { + try { + Thread.sleep(300); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + + if (running.get()) { + runClient(); + } + }, "vnc-fallback-retry").start(); + + return; + } + + if (triedFallbackEncodings && useFallbackEncodings && !usedFallbackSuccessfully && !gotAnyFramebufferUpdate) { + if (recoveryListener != null) { + recoveryListener.onVncReconnectFailedPermanently(); + } else { + notifyError("Compatibility fallback failed"); + } + return; + } + + notifyError("VNC error: " + e.getClass().getSimpleName() + ": " + e.getMessage()); + } + + // ========================================================================= + // Listener notifications + // ========================================================================= + + private void notifyConnected() { + if (listener != null) { + listener.onConnected(); + } + } + + private void notifyFirstFrameReceived() { + if (listener != null && firstFrameNotified.compareAndSet(false, true)) { + listener.onFirstFrameReceived(); + sendWakeClick(); + } + } + private void sendWakeClick() { + try { + if (rfb == null || !connected.get() || bitmapData == null) { + return; + } + + int x = bitmapData.getFramebufferWidth() - 10; + int y = bitmapData.getFramebufferHeight() - 10; + + rfb.writeClickAndFramebufferRequest( + x, + y, + bitmapData.getFramebufferWidth(), + bitmapData.getFramebufferHeight() + ); + } catch (Exception error) { + handleConnectionLostOrFailed(error); + } + } + + private void notifyFrameUpdated() { + if (listener != null && bitmapData != null && bitmapData.isDirty() && frameNotifyPending.compareAndSet(false, true)) { + try { + listener.onFrameUpdated( + bitmapData.copyPixelBuffer(), + bitmapData.getFramebufferWidth(), + bitmapData.getFramebufferHeight() + ); + bitmapData.clearDirty(); + } finally { + frameNotifyPending.set(false); + } + } + } + + private void notifyError(String message) { + if (listener != null) { + listener.onError(message); + } + } + + private void notifyDisconnected() { + if (listener != null) { + listener.onDisconnected(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/vnc/rfb/ZlibInStreamDesktop.java b/src/main/java/com/litoralregas/backend/vnc/rfb/ZlibInStreamDesktop.java new file mode 100644 index 0000000..fcb6ae1 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/vnc/rfb/ZlibInStreamDesktop.java @@ -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); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/vnc/websocket/VncStreamHandler.java b/src/main/java/com/litoralregas/backend/vnc/websocket/VncStreamHandler.java new file mode 100644 index 0000000..6ce34eb --- /dev/null +++ b/src/main/java/com/litoralregas/backend/vnc/websocket/VncStreamHandler.java @@ -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 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); + } +} \ No newline at end of file diff --git a/src/main/java/com/litoralregas/backend/vnc/websocket/VncWebSocketConfig.java b/src/main/java/com/litoralregas/backend/vnc/websocket/VncWebSocketConfig.java new file mode 100644 index 0000000..af6f3f3 --- /dev/null +++ b/src/main/java/com/litoralregas/backend/vnc/websocket/VncWebSocketConfig.java @@ -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("*"); + } +} \ No newline at end of file