本文概述
在计算中, 虚拟网络计算(VNC)是一个图形桌面共享系统, 它使用远程帧缓冲区(RFB)协议来远程控制另一台计算机。它将键盘和鼠标事件从一台计算机传输到另一台计算机, 并通过网络将图形屏幕更新沿另一方向传递回去。
RFB是用于远程访问图形用户界面的简单协议。因为它在帧缓冲区级别起作用, 所以适用于所有窗口系统和应用程序, 包括Microsoft Windows, Mac OS X和X Window System。
用Java构建由Remote Framebuffer服务器端协议支持的Swing应用程序
鸣叫
在本文中, 我将展示如何实现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种类型:
- SetPixelFormat
- SetEncodings
- 帧缓冲更新请求
- 按键事件
- 指针事件
- ClientCutText
协议文档非常准确, 并解释了每条消息。对于每个消息, 将解释每个字节。例如, 服务器初始化消息:
字节数 | 类型 | 描述 |
---|---|---|
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类获取主窗口的屏幕截图并将其发送给客户端。
如果是增量请求, 则将一个标记incrementalFrameBufferUpdate设置为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和紧密。但是, 为了使事情简单易懂, 我们将在实现中使用原始编码。
原始编码意味着像素颜色作为RGB分量传输。如果客户端将像素编码设置为32位, 则每个像素传输4个字节。如果客户端使用8位彩色模式, 则每个像素以1字节传输。代码在for循环中显示。请注意, 对于8位模式, 颜色映射用于从屏幕快照/图像缓冲区中找到每个像素的最佳匹配。对于32位像素模式, 图像缓冲区包含整数数组, 每个值都具有多路复用的RGB分量。
演示应用程序
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>成员中维护客户端列表的原因。
可以在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()函数中创建的。
结果是, Java中基于Swing的应用程序充当RFB服务提供者, 并允许标准VNC查看器连接到它:
总结
RFB协议被广泛使用和接受。几乎所有平台和设备都采用VNC查看器形式的客户端实现。主要目的是远程显示桌面, 但也可以有其他应用程序。例如, 你可以创建漂亮的图形工具并远程访问它们, 以增强现有的远程工作流程。
本文介绍了RFB协议的基础知识, 消息格式, 如何发送部分屏幕以及如何处理键盘和鼠标。 GitHub上提供了带有Swing演示应用程序的完整源代码。
评论前必须登录!
注册