用 Java 实现远程帧缓冲服务器
已发表: 2022-03-11在计算中,虚拟网络计算 (VNC) 是一种图形桌面共享系统,它使用远程帧缓冲区 (RFB) 协议来远程控制另一台计算机。 它将键盘和鼠标事件从一台计算机传输到另一台计算机,并通过网络将图形屏幕更新转发回另一个方向。
RFB 是一种用于远程访问图形用户界面的简单协议。 因为它工作在帧缓冲区级别,所以它适用于所有窗口系统和应用程序,包括 Microsoft Windows、Mac OS X 和 X Window System。
在本文中,我将展示如何实现 RFB 服务器端协议,并通过一个小型 Java Swing 应用程序演示如何通过 TCP 连接将主窗口传输到 VNC 查看器。 这个想法是为了演示协议的基本特性和 Java 中的可能实现。
读者应具备 Java 编程语言的基本知识,并应熟悉 TCP/IP 网络、客户端-服务器模型等基本概念。理想情况下,读者是 Java 开发人员,并且对 RealVNC 等知名 VNC 实现有一定的经验、UltraVNC、TightVNC等
远程帧缓冲协议规范
RFB 协议规范定义得非常好。 根据维基百科,RFB 协议有几个版本。 对于本文,我们的重点将放在大多数 VNC 实现应该正确理解的常见消息,而不管协议版本如何。
在 VNC 查看器(客户端)与 VNC 服务器(RFB 服务)建立 TCP 连接后,第一阶段涉及协议版本的交换:
RFB Service ----------- "RFB 003.003\n" -------> VNC viewer RFB Service <---------- "RFB 003.008\n" -------- VNC viewer
它是一个简单的字节流,可以解码为 ASCII 字符,例如“RFB 003.008\n”。
完成后,下一步就是身份验证。 VNC 服务器发送一个字节数组来指示它支持的身份验证类型。 例如:
RFB Service ----------- 0x01 0x02 -----------> VNC viewer RFB Service <----------- 0x02 ----------- VNC viewer
这里 VNC 服务器只发送了 1 种可能的身份验证类型 (0x02)。 第一个字节 0x01 表示可用的身份验证类型的数量。 VNC 查看器必须回复值 0x02,因为这是本示例中服务器支持的唯一可能类型。
接下来,服务器将发送身份验证质询(取决于哪种算法,有几种),客户端必须以正确的质询响应消息进行响应,并等待服务器确认响应。 一旦客户端通过身份验证,他们就可以继续会话建立过程。
这里最简单的方法是完全不选择身份验证。 不管身份验证机制如何,RFB 协议都是不安全的。 如果安全很重要,正确的方法是通过 VPN 或 SSH 连接来隧道 RFB 会话。
此时,VNC 查看器会发送一条共享桌面消息,告知客户端是否将共享并允许其他 VNC 查看器连接到同一桌面。 由 RFB 服务实施来考虑该消息并可能阻止多个 VNC 查看器共享同一屏幕。 该消息只有 1 个字节长,有效值为 0x00 或 0x01。
最后,RFB 服务器发送一个服务器初始化消息,其中包含屏幕尺寸、每像素位数、深度、大端标志和真彩色标志、红色、绿色和蓝色的最大值、红色、绿色和蓝色的像素位位置, 和桌面字符串/标题。 前两个字节以像素为单位表示屏幕宽度,接下来的两个字节是屏幕高度。 在屏幕高度字节之后,每像素字节的位数应该出现在消息中。 该值通常为 8、16 或 32。在大多数具有全色范围的现代系统上,每像素字节的位数为 32 (0x20)。 它告诉客户端它可以从服务器请求每个像素的全彩色。 仅当像素按大端顺序排列时,大端字节才非零。 如果真彩色字节不为零(真),那么接下来的六个字节指定如何从像素值中提取红色、绿色和蓝色强度。 接下来的六个字节是像素的红色、绿色和蓝色分量的最大允许值。 这在 8 位颜色模式中很重要,其中每个颜色分量只有几个位可用。 红色、绿色和蓝色移位确定每种颜色的位位置。 最后三个字节是填充,应该被客户端忽略。 在像素格式之后,有一个字节定义了桌面标题的字符串长度。 桌面标题是任意长度的字节数组中的 ASCII 编码字符串。
在服务器初始化消息之后,RFB 服务应该从套接字读取客户端消息并对其进行解码。 有 6 种类型的消息:
- 设置像素格式
- 设置编码
- 帧缓冲更新请求
- 按键事件
- 指针事件
- 客户端剪切文本
协议文档非常准确,并解释了每条消息。 对于每条消息,每一个字节都会被解释。 例如,服务器初始化消息:
字节数 | 类型 | 描述 |
---|---|---|
2 | U16 | 帧缓冲宽度 |
2 | U16 | 帧缓冲高度 |
16 | PIXEL_FORMAT | 服务器像素格式 |
4 | U32 | 名称长度 |
名称长度 | U8阵列 | 名称字符串 |
在这里,PIXEL_FORMAT 是:
字节数 | 类型 | 描述 |
---|---|---|
1 | U8 | 每像素位数 |
1 | U8 | 深度 |
1 | U8 | 大端标志 |
1 | U8 | 真彩旗 |
2 | U16 | 红最大 |
2 | U16 | 绿色最大 |
2 | U16 | 蓝最大 |
1 | U8 | 红移 |
1 | U8 | 绿移 |
1 | U8 | 蓝移 |
3 | 填充 |
U16 表示无符号 16 位整数(两个字节),U32 表示无符号 32 位整数,U8 数组是字节数组等。
Java中的协议实现
一个典型的 Java 服务器应用程序由一个监听客户端连接的线程和几个处理客户端连接的线程组成。
/* * Use TCP port 5902 (display :2) as an example to listen. */ int port = 5902; ServerSocket serverSocket; serverSocket = new ServerSocket(port); /* * Limit sessions to 100. This is lazy way, if * somebody really open 100 sessions, server socket * will stop listening and no new VNC viewers will be * able to connect. */ while (rfbClientList.size() < 100) { /* * Wait and accept new client. */ Socket client = serverSocket.accept(); /* * Create new object for each client. */ RFBService rfbService = new RFBService(client); /* * Add it to list. */ rfbClientList.add(rfbService); /* * Handle new client session in separate thread. */ (new Thread(rfbService, "RFBService" + rfbClientList.size())).start(); }
这里选择了 TCP 端口 5902(显示:2),while 循环等待客户端连接。 方法ServerSocket.accept()是阻塞的,它使线程等待新的客户端连接。 客户端连接后,会创建一个新线程 RFBService,用于处理从客户端接收到的 RFB 协议消息。
RFBService 类实现了 Runnable 接口。 它充满了从套接字读取字节的方法。 方法run()很重要,当线程在循环结束时启动时立即执行:
@Override public void run() { try { /* * RFB server has to send protocol version string first. * And wait for VNC viewer to replay with * protocol version string. */ sendProtocolVersion(); String protocolVer = readProtocolVersion(); if (!protocolVer.startsWith("RFB")) { throw new IOException(); }
这里方法sendProtocolVersion()将 RFB 字符串发送到客户端(VNC 查看器),然后从客户端读取协议版本字符串。 客户应回复“RFB 003.008\n”之类的内容。 方法readProtocolVersion()当然是阻塞的,就像任何名称以 read 开头的方法一样。
private String readProtocolVersion() throws IOException { byte[] buffer = readU8Array(12); return new String(buffer); }
方法 readProtocolVersion() 很简单:它从套接字读取 12 个字节,并返回一个字符串值。 函数 readU8Array(int) 读取指定数量的字节,在本例中为 12 个字节。 如果在套接字上没有足够的字节读取,它会等待:
private byte[] readU8Array(int len) throws IOException { byte[] buffer = new byte[len]; int offset = 0, left = buffer.length; while (offset < buffer.length) { int numOfBytesRead = 0; numOfBytesRead = in.read(buffer, offset, left); offset = offset + numOfBytesRead; left = left - numOfBytesRead; } return buffer; }
与readU8Array(int)类似,存在readU16int()和readU32int()方法,它们从套接字读取字节并返回整数值。
发送协议版本并读取响应后,RFB 服务应发送安全消息:
/* * RFB server sends security type bytes that may request * a user to type password. * In this implementation, this is set to simples * possible option: no authentication at all. */ sendSecurityType();
在这个实现中,选择了最简单的方法:不需要来自 VNC 客户端的任何密码。
private void sendSecurityType() throws IOException { out.write(SECURITY_TYPE); out.flush(); }
其中 SECURITY_TYPE 是字节数组:
private final byte[] SECURITY_TYPE = {0x00, 0x00, 0x00, 0x01};
RFB 协议版本 3.3 的这个字节数组意味着 VNC 查看器不需要发送任何密码。
接下来 RFB 服务应该从客户端获取共享桌面标志。 它是套接字上的一个字节。
/* * RFB server reads shared desktop flag. It's a single * byte that tells RFB server * should it support multiple VNC viewers connected at * same time or not. */ byte sharedDesktop = readSharedDesktop();
从套接字读取共享桌面标志后,我们在实现中忽略它。
RFB 服务必须发送服务器初始化消息:
/* * RFB server sends ServerInit message that includes * screen resolution, * number of colors, depth, screen title, etc. */ screenWidth = JFrameMainWindow.jFrameMainWindow.getWidth(); screenHeight = JFrameMainWindow.jFrameMainWindow.getHeight(); String windowTitle = JFrameMainWindow.jFrameMainWindow.getTitle(); sendServerInit(screenWidth, screenHeight, windowTitle);
JFrameMainWindow 类是 JFrame,在这里作为图形源用于演示目的。 服务器初始化消息具有以像素为单位的强制性屏幕宽度和高度,以及桌面标题。 在这个例子中,它是通过 getTitle() 方法获得的 JFrame 的标题。
在服务器初始化消息之后,RFB 服务线程通过从套接字读取六种类型的消息进行循环:
/* * Main loop where clients messages are read from socket. */ while (true) { /* * Mark first byte and read it. */ in.mark(1); int messageType = in.read(); if (messageType == -1) { break; } /* * Go one byte back. */ in.reset(); /* * Depending on message type, read complete message on socket. */ if (messageType == 0) { /* * Set Pixel Format */ readSetPixelFormat(); } else if (messageType == 2) { /* * Set Encodings */ readSetEncoding(); } else if (messageType == 3) { /* * Frame Buffer Update Request */ readFrameBufferUpdateRequest(); } else if (messageType == 4) { /* * Key Event */ readKeyEvent(); } else if (messageType == 5) { /* * Pointer Event */ readPointerEvent(); } else if (messageType == 6) { /* * Client Cut Text */ readClientCutText(); } else { err("Unknown message type. Received message type = " + messageType); } }
每个方法readSetPixelFormat() , readSetEncoding() , readFrameBufferUpdateRequest() , ... readClientCutText()都是阻塞的并触发一些动作。
例如,当用户在客户端剪切文本时, readClientCutText()方法读取消息中编码的文本,然后 VNC 查看器通过 RFB 协议将文本发送到服务器。 然后将文本放置在剪贴板的服务器端。
客户端消息
RFB 服务必须支持所有六个消息,至少在字节级别:当客户端发送消息时,必须读取完整的字节长度。 这是因为 RFB 协议是面向字节的,两个消息之间没有边界。
最重要的消息是帧缓冲区更新请求。 客户端可以请求屏幕的完全更新或增量更新。
private void readFrameBufferUpdateRequest() throws IOException { int messageType = in.read(); int incremental = in.read(); if (messageType == 0x03) { int x_pos = readU16int(); int y_pos = readU16int(); int width = readU16int(); int height = readU16int(); screenWidth = width; screenHeight = height; if (incremental == 0x00) { incrementalFrameBufferUpdate = false; int x = JFrameMainWindow.jFrameMainWindow.getX(); int y = JFrameMainWindow.jFrameMainWindow.getY(); RobotScreen.robo.getScreenshot(x, y, width, height); sendFrameBufferUpdate(x_pos, y_pos, width, height, 0, RobotScreen.robo.getColorImageBuffer()); } else if (incremental == 0x01) { incrementalFrameBufferUpdate = true; } else { throw new IOException(); } } else { throw new IOException(); } }
帧缓冲请求消息的第一个字节是消息类型。 值始终为 0x03。 下一个字节是增量标志,它告诉服务器发送全帧或只是一个差异。 在完整更新请求的情况下,RFB 服务将使用 RobotScreen 类截取主窗口并将其发送给客户端。

如果是增量请求,一个标志增量FrameBufferUpdate将被设置为true。 Swing 组件将使用此标志来检查它们是否需要发送已更改的屏幕部分。 通常 JMenu、JMenuItem、JTextArea 等需要在用户移动鼠标指针、点击、发送按键等时对屏幕进行增量更新。
方法 sendFrameBufferUpdate(int, int, int, int, int[]) 将图像缓冲区刷新到套接字。
public void sendFrameBufferUpdate(int x, int y, int width, int height, int encodingType, int[] screen) throws IOException { if (x + width > screenWidth || y + height > screenHeight) { err ("Invalid frame update size:"); err (" x = " + x + ", y = " + y); err (" width = " + width + ", height = " + height); return; } byte messageType = 0x00; byte padding = 0x00; out.write(messageType); out.write(padding); int numberOfRectangles = 1; writeU16int(numberOfRectangles); writeU16int(x); writeU16int(y); writeU16int(width); writeU16int(height); writeS32int(encodingType); for (int rgbValue : screen) { int red = (rgbValue & 0x000000FF); int green = (rgbValue & 0x0000FF00) >> 8; int blue = (rgbValue & 0x00FF0000) >> 16; if (bits_per_pixel == 8) { out.write((byte) colorMap.get8bitPixelValue(red, green, blue)); } else { out.write(red); out.write(green); out.write(blue); out.write(0); } } out.flush(); }
方法检查 (x, y) 坐标是否与图像缓冲区的宽度 x 高度一起离开屏幕。 帧缓冲区更新的消息类型值为 0x00。 填充值通常为 0x00,应被 VNC 查看器忽略。 矩形数量是两个字节的值,定义了消息中跟随的矩形数量。
每个矩形都有左上角坐标、宽度和高度、编码类型和像素数据。 有一些高效的编码格式可以使用,例如zrle、hextile和tight。 然而,为了让事情简单易懂,我们将在我们的实现中使用原始编码。
原始编码意味着像素颜色作为 RGB 分量传输。 如果客户端已将像素编码设置为 32 位,则每个像素传输 4 个字节。 如果客户端使用 8 位颜色模式,则每个像素作为 1 个字节传输。 代码显示在 for 循环中。 请注意,对于 8 位模式,颜色映射用于从屏幕截图/图像缓冲区中找到每个像素的最佳匹配。 对于 32 位像素模式,图像缓冲区包含整数数组,每个值都具有多路复用的 RGB 分量。
Swing 演示应用程序
Swing 演示应用程序包含触发sendFrameBufferUpdate(int, int, int, int, int[])方法的动作侦听器。 通常应用程序元素,如 Swing 组件,应该有监听器并向客户端发送屏幕变化。 例如当用户在 JTextArea 中键入内容时,它应该被传输到 VNC 查看器。
public void actionPerformed(ActionEvent arg0) { /* * Get dimensions and location of main JFrame window. */ int offsetX = JFrameMainWindow.jFrameMainWindow.getX(); int offsetY = JFrameMainWindow.jFrameMainWindow.getY(); int width = JFrameMainWindow.jFrameMainWindow.getWidth(); int height = JFrameMainWindow.jFrameMainWindow.getHeight(); /* * Do not update screen if main window dimension has changed. * Upon main window resize, another action listener will * take action. */ int screenWidth = RFBDemo.rfbClientList.get(0).screenWidth; int screenHeight = RFBDemo.rfbClientList.get(0).screenHeight; if (width != screenWidth || height != screenHeight) { return; } /* * Capture new screenshot into image buffer. */ RobotScreen.robo.getScreenshot(offsetX, offsetY, width, height); int[] delta = RobotScreen.robo.getDeltaImageBuffer(); if (delta == null) { offsetX = 0; offsetY = 0; Iterator<RFBService> it = RFBDemo.rfbClientList.iterator(); while (it.hasNext()) { RFBService rfbClient = it.next(); if (rfbClient.incrementalFrameBufferUpdate) { try { /* * Send complete window. */ rfbClient.sendFrameBufferUpdate( offsetX, offsetY, width, height, 0, RobotScreen.robo.getColorImageBuffer()); } catch (SocketException ex) { it.remove(); } catch (IOException ex) { ex.printStackTrace(); it.remove(); } rfbClient.incrementalFrameBufferUpdate = false; } } } else { offsetX = RobotScreen.robo.getDeltaX(); offsetY = RobotScreen.robo.getDeltaY(); width = RobotScreen.robo.getDeltaWidth(); height = RobotScreen.robo.getDeltaHeight(); Iterator<RFBService> it = RFBDemo.rfbClientList.iterator(); while (it.hasNext()) { RFBService rfbClient = it.next(); if (rfbClient.incrementalFrameBufferUpdate) { try { /* * Send only delta rectangle. */ rfbClient.sendFrameBufferUpdate( offsetX, offsetY, width, height, 0, delta); } catch (SocketException ex) { it.remove(); } catch (IOException ex) { ex.printStackTrace(); it.remove(); } rfbClient.incrementalFrameBufferUpdate = false; } } } }
这个动作监听器的代码非常简单:它使用 RobotScreen 类截取主窗口 JFrameMain 的屏幕截图,然后确定是否需要部分更新屏幕。 变量diffUpdateOfScreen用作部分更新的标志。 最后完成图像缓冲区或仅将不同的行传输给客户端。 这段代码还考虑了更多的客户端连接,这就是为什么使用迭代器并且在RFBDemo.rfbClientList<RFBService>成员中维护客户端列表的原因。
Framebuffer 更新动作监听器可以在 Timer 中使用,它可以由任何 JComponent 更改启动:
/* * Define timer for frame buffer update with 400 ms delay and * no repeat. */ timerUpdateFrameBuffer = new Timer(400, new ActionListenerFrameBufferUpdate()); timerUpdateFrameBuffer.setRepeats(false);
此代码在 JFrameMainWindow 类的构造函数中。 计时器在 doIncrementalFrameBufferUpdate() 方法中启动:
public void doIncrementalFrameBufferUpdate() { if (RFBDemo.rfbClientList.size() == 0) { return; } if (!timerUpdateFrameBuffer.isRunning()) { timerUpdateFrameBuffer.start(); } }
其他动作监听器通常调用 doIncrementalFrameBufferUpdate() 方法:
public class DocumentListenerChange implements DocumentListener { @Override public void changedUpdate(DocumentEvent e) { JFrameMainWindow jFrameMainWindow = JFrameMainWindow.jFrameMainWindow; jFrameMainWindow.doIncrementalFrameBufferUpdate(); } // ... }
这种方式应该简单易行。 只需要对 JFrameMainWindow 实例的引用和一次调用doIncrementalFrameBufferUpdate()方法。 方法将检查是否有客户端连接,如果有,定时器timerUpdateFrameBuffer将被启动。 一旦计时器启动,动作监听器将实际截取屏幕截图并执行sendFrameBufferUpdate() 。
上图显示了侦听器与帧缓冲区更新过程的关系。 大多数侦听器在用户执行操作时触发:单击、选择文本、在文本区域中键入内容等。然后执行成员函数doIncrementalFramebufferUpdate()来启动计时器timerUpdateFrameBuffer 。 计时器最终将调用RFBService 类中的 sendFrameBufferUpdate()方法,它会导致客户端(VNC 查看器)上的屏幕更新。
捕获屏幕、播放击键和在屏幕上移动鼠标指针
Java 有一个内置的 Robot 类,使开发人员能够编写一个应用程序来抓取屏幕截图、发送键、操作鼠标指针、产生点击等。
要抓取显示 JFrame 窗口的屏幕区域,使用 RobotScreen。 主要方法是获取屏幕区域的getScreenshot(int, int, int, int) 。 每个像素的 RGB 值存储在 int[] 数组中:
public void getScreenshot(int x, int y, int width, int height) { Rectangle screenRect = new Rectangle(x, y, width, height); BufferedImage colorImage = robot.createScreenCapture(screenRect); previousImageBuffer = colorImageBuffer; colorImageBuffer = ((DataBufferInt) colorImage.getRaster().getDataBuffer()).getData(); if (previousImageBuffer == null || previousImageBuffer.length != colorImageBuffer.length) { previousImageBuffer = colorImageBuffer; } this.width = width; this.height = height; }
方法将像素存储在 colorImageBuffer 数组中。 要获取像素数据,可以使用getColorImageBuffer()方法。
方法还保存以前的图像缓冲区。 可以仅获取已更改的像素。 要仅获取图像区域的差异,请使用方法getDeltaImageBuffer() 。
使用 Robot 类很容易将击键发送到系统。 但是,必须首先正确翻译从 VNC 查看器收到的一些特殊键码。 RobotKeyboard 类具有处理特殊键和字母数字键的方法sendKey(int, int) :
public void sendKey(int keyCode, int state) { switch (keyCode) { case 0xff08: doType(VK_BACK_SPACE, state); break; case 0xff09: doType(VK_TAB, state); break; case 0xff0d: case 0xff8d: doType(VK_ENTER, state); break; case 0xff1b: doType(VK_ESCAPE, state); break; … case 0xffe1: case 0xffe2: doType(VK_SHIFT, state); break; case 0xffe3: case 0xffe4: doType(VK_CONTROL, state); break; case 0xffe9: case 0xffea: doType(VK_ALT, state); break; default: /* * Translation of a..z keys. */ if (keyCode >= 97 && keyCode <= 122) { /* * Turn lower-case a..z key codes into upper-case A..Z key codes. */ keyCode = keyCode - 32; } doType(keyCode, state); } }
参数状态确定是按下还是释放键。 将键码正确转换为 VT 常量后,方法doType(int, int)将键值传递给 Robot,效果与本地用户按键盘上的键相同:
private void doType(int keyCode, int state) { if (state == 0) { robot.keyRelease(keyCode); } else { robot.keyPress(keyCode); } }
与 RobotKeyboard 类似的是 RobotMouse 类,它处理指针事件,并导致鼠标指针移动和单击。
public void mouseMove(int x, int y) { robot.mouseMove(x, y); }
RobotScreen、RobotMouse 和 RobotKeyboard 这三个类都在构造函数中分配新的 Robot 实例:
this.robot = new Robot();
我们只有一个实例,因为在应用程序级别不需要有多个 RobotScreen、RobotMouse 或 RobotKeyboard 类的实例。
public static void main(String[] args) { ... /* * Initialize static Robot objects for screen, keyboard and mouse. */ RobotScreen.robo = new RobotScreen(); RobotKeyboard.robo = new RobotKeyboard(); RobotMouse.robo = new RobotMouse(); ... }
在这个演示应用程序中,这些实例是在main()函数中创建的。
结果是一个基于 Swing 的 Java 应用程序,它充当 RFB 服务提供者并允许标准 VNC 查看器连接到它:
结论
RFB 协议被广泛使用和接受。 几乎所有平台和设备都存在 VNC 查看器形式的客户端实现。 主要目的是远程显示桌面,但也可以有其他应用程序。 例如,您可以创建漂亮的图形工具并远程访问它们以增强您现有的远程工作流程。
本文介绍了 RFB 协议的基础知识、消息格式、如何发送部分屏幕以及如何处理键盘和鼠标。 GitHub 上提供了 Swing 演示应用程序的完整源代码。