远程访问 .NET CF 设备






4.94/5 (15投票s)
实现对启用 .NET 的设备进行远程访问。
- 下载 TCP 服务器 (.NET CF 2.0 Windows 应用程序) - 101 KB
- 下载 TCP Windows 客户端 (.NET 2.0 命令行应用程序) - 97.2 KB
- 下载 TCP Windows 客户端 (.NET 2.0 Windows 应用程序) - 123 KB
- 下载 TCP Windows 服务器 (.NET 2.0 Windows 应用程序) - 101 KB
- 下载源代码 - 258 KB
目录
引言
这是一个简单的 TCP 客户端/服务器应用程序。我编写它是为了能通过公司网络,以一种简单的方式连接到各种无线移动扫描仪。所有代码都使用 C# 编写,基于 .NET 2.0。
你可以在移动设备上使用 TCP 服务器,在 PC 上使用 TCP 客户端。PC 客户端有两种版本:一种是命令行客户端,你可以用它来编写某种脚本;另一种是 Windows 图形界面,当然,它更美观、更易于使用。还有一个 PC TCP 服务器,所以如果你真的想,也可以从一台 PC 连接到另一台 PC,但有很多更好且免费的程序可以做到这一点。这个程序最适合的应用场景是从你的 PC 连接到任何支持 .NET 的移动设备。你可以操作移动设备上的文件系统并获取屏幕截图。还有一个非常基础的远程控制移动设备的功能,但它真的非常、非常基础。
在本文中,你可以看到/学习它是如何构建的,并且可以采纳它并开发新的功能,以更好地满足你的需求。
背景
作为一名开发人员,我在许多平台上开发程序,其中之一就是 .NET CF(在 Motorola/Symbol 移动扫描仪上)。我们在全国各地都有商店,每家商店都有用于各种应用的移动扫描仪。
我需要一种简单的方法来连接这些扫描仪,并从中检索一些数据,或者反过来——向它们发送一些数据。由于 .NET CF 在一些高级的——基于 Windows 的——协议上功能相当有限,我决定采用经典的 TCP 客户端/服务器架构。
这个决定相当不错,因为它意味着我可以在任何支持 .NET 的设备上编写 .NET TCP 客户端和 .NET TCP 服务器;因此,我能够在 PC 上测试所有东西,然后将其移植到 .NET CF。
项目设定
这些设定非常重要,因为我在整个开发周期中所做的决定都是基于它们。
- .NET CF 设备(移动扫描仪)将为客户端提供服务(因此它们将是主机)。
- 客户端将是基于 Windows 的 PC。
- 预期的任务是:
- 从扫描仪获取一些数据(文件夹/文件信息、整个文件、屏幕截图)。
- 向扫描仪发送一些数据(文件)。
- 系统必须支持运行时升级,因此预计用户会对某些特定任务有需求,例如:
- 最新的日志文件,
- 正在运行的进程信息,
- 已安装的应用程序信息,
- 无线设置信息,
- 等等...
- 保持简单。
沟通
因为这个项目的核心是在 TCP 客户端(Windows PC)和 TCP 服务器(移动设备)之间交换信息,所以通信是整个项目的基石。
通常情况下,客户端通过向服务器发送一些信息来开始通信。当然,所有需要的东西还是目标地址(URL)、通信端口和信息本身。当服务器接收到消息后,它会向客户端发回响应,然后断开连接,或者保持连接活动状态以实现更快的重新通信。
从这个简短的介绍可以看出,唯一未定义的就是消息本身。所以,我们必须定义客户端和服务器之间的消息(信息)。我决定为所有目的使用一种消息格式。消息本身当然可以携带不同的信息,但结构是相同的。
消息结构
字节 | 长度(字节) | Info(信息) |
---|---|---|
0...0 | 1 | 前缀 |
1...1 | 1 | 版本 |
2...5 | 4 | 分段数量 |
Segment | 1 | 信息分段 1(由分段结构定义) |
Segment | 2 | 信息分段 2(由分段结构定义) |
... | ... | |
Segment | N | 信息分段 N(由分段结构定义) |
分段结构
字节 | 长度(字节) | Info(信息) |
---|---|---|
0...3 | 4 | 分段的长度(字节) |
4...4 | 1 | 分段的类型 |
5... | 在前 4 个字节中定义 | Data |
为了完全定义消息结构,我们需要进一步的(详细的)定义:
- 消息的前缀是什么?目前是一个大写的 X,即 'X'。
- 版本是什么,以及如何写入?当前版本是 2,写入为 '2'(ASCII 码 49)。
- 一条消息有 4 个字节定义消息中的分段数量,每个分段定义自己的长度。那么,这 4 个字节是小端序还是大端序?这无关紧要,因为我们的设定是只在 .NET 环境中工作,所以 .NET 会负责在通信双方将
int
转换为byte[]
,反之亦然。 - 分段的“类型”是什么?类型是一个
enum
(枚举),用于对分段进行基本描述。分段可以是 XML、二进制数据、文本数据、压缩数据等。
消息内容详解
每条消息至少有一个分段,并且它始终是描述整条消息目的的 XML 数据。所有其他分段只是消息本身所需的附加数据。
在这个软件的第一个版本中,每条消息都是 XML,其中包含描述服务器必须执行的操作的动作,或者包含服务器已完成操作的信息的 XML。这种方式运行得很好,易于理解和操作。但问题在于,很多消息涉及某种二进制数据操作——发送文件、获取文件、获取屏幕截图等等。因此,在我的第一个版本中,所有这些二进制数据都作为 CODE64 加密的二进制数据保存在 XML 内部。这样做没问题,也能工作,但效率很低,因为服务器端需要将二进制数据编码为 CODE64,而客户端则需要在另一端进行解码。但即使这样也不是问题,因为转换速度很快。真正的问题是大小。每个用 CODE64 编码的二进制数据大约会增大一倍,这意味着消息更大,从而导致通过无线网络通信更慢(别忘了我们谈论的是无线扫描仪!)。
因此,在版本 2 中,我决定保留 XML 的使用,因为它有很多优点,但每当需要处理二进制数据时,就会创建一个新的分段,只在 XML 中留下指向该分段的指针——这就是分段的全部意义所在。
现在是时候深入代码了,这里是消息和分段的接口:
public interface ISegment
{
int Length { get; }
SegmentType Type { get; }
byte[] Data { get; }
byte[] UnCompressData { get; }
XmlDocument XmlDocument { get; }
void ReadFromStream(Stream stream);
void SendToStream(Stream stream);
bool Compress();
void SaveToFile(string fileName, bool createOnly);
void LoadFromFile(string fileName, bool compress);
}
public interface IMessage
{
int Version { get; }
void ReadFromStream(Stream stream);
void SendToStream(Stream stream);
IMessage Execute(ExecuteTime executeTime);
byte[] GetJobData(XmlElement xmlJob, bool uncompress);
XmlDocument XmlDocument { get; }
List<ISegment> Segments { get; }
}
XML,或者说消息(如果我们这样看的话),是由服务器必须完成的不同作业(job)组成的。我们可以将一个作业看作是为完成工作而必须执行的某种动作(action)。而且,每个作业都有一些属性,有些是可选的,有些不是。
这是一个包含一些作业的消息示例:
<?xml version='1.0' encoding='windows-1250'?>
<jobs>
<job action = 'getPluginsData'/>
<job action = 'reloadPlugins'/>
<job action = 'dir'
folder = '\windows'
recursive = '1'
compress = '0'
filePattern = '*.exe'/>
<job action = 'putFile'
inFile = 'c:\temp\test1.txt'
outFile = '\application data\test.txt' compress='1'/>
<job action = 'deleteFile'
file = 'test.txt'/>
<job action = 'createFolder'
folder = '\windows\test'/>
<job action = 'capture'
format = 'bmp'
compress = '1'
outFile = 'd:\temp\picture1.bmp'/>
</jobs>
插件
正如在设定中所见,我们需要一种机制来升级软件。我所说的升级,是指在不重新编程服务器或客户端的情况下添加功能;而这最好通过插件来实现。因此,服务器和客户端必须知道如何安装和使用插件。
插件非常简单,因为它们只需要一些简单的信息:
- 名称,
- 作者,
- 版本,以及
- 操作列表。
操作列表被用作触发器来初始化正确的插件。可以想象,每个插件都是为了执行一些作业(操作)而设计的,这就是它们的操作列表。
所以这个想法很简单:每当你添加一个新插件时,程序就会记住它公开的操作列表。之后,当需要执行这样的操作时,程序只需调用这个插件并要求它执行选定的操作。
public interface IBasicPluginData
{
string Name { get; }
string Version { get; }
string Author { get; }
}
public interface IPlugin
{
IBasicPluginData BasicData { get; }
IList<string> Actions { get; }
ExecuteAction GetExecuteFunction(ExecuteTime executeTime, string action);
}
我稍后会解释 GetExecuteFunction
方法的用途。
消息流
我之所以谈论消息流,是因为消息从用户界面流向客户端,再到服务器,然后从服务器返回到客户端,最后回到用户界面。要了解这是如何工作的,最好直接看一些例子。
首先,我将讨论一个简单的操作,“getPicture”。顾名思义,该操作在移动设备上截取屏幕图像并将其返回给用户。然后,我将展示 getFile(从移动设备获取文件),最后,我将展示 putFile(将选定的文件放到移动设备上)。
getPicture 流程
- 用户界面:用户请求图片 ==> getPicture
- TCP 客户端:转发 getPicture 操作
- TCP 服务器:执行 getPicture 并将数据放入其中一个分段中
- TCP 客户端:将数据转发给客户端
- 用户界面:显示图片
getFile 流程
- 用户界面:用户请求一个文件 ==> getFile
- TCP 客户端:转发 getFile 操作
- TCP 服务器:执行 getFile 并将数据放入其中一个分段中
- TCP 客户端:将文件保存在本地文件系统上
- 用户界面:通知用户结果
putFile 流程
- 用户界面:用户想要发送一个文件到移动设备 ==> putFile
- TCP 客户端:从本地文件系统读取文件并将数据保存到其中一个分段中
- TCP 服务器:将数据(从分段中)保存到本地文件
- TCP 客户端:转发结果
- 用户界面:通知用户结果
我们能从这三个例子中学到什么?从中我学到,在客户端,每个操作都可能在我们调用服务器之前和服务器执行完其工作之后产生影响。当然,在服务器上,我们只需要一个函数来执行操作。如果我们从插件的角度来看,很明显,客户端插件必须有两个函数:“beforeServer”和“afterServer”,而服务器插件只需要一个函数:“executeAction”。
使用插件
好的,插件只是动态加载的 C# 模块。每个动态加载的模块都会被检查类特性(class attribute),如果在模块中找到了正确的类,那么这个类就会被用作某个操作的插件。而且,因为每个插件类都必须实现 IPlugin
接口,我们可以使用它的 GetExecuteFunction
方法来获取选定操作的正确函数。
到目前为止,我也揭示了 GetExecuteFunction
方法和 ExecuteTime
枚举的用途。你还可以看到 ExecuteAction
委托的定义,它当然只是接收一些消息作为输入,处理消息,并将结果作为输出消息返回。
public enum ExecuteTime
{
BeforeServer,
OnServer,
AfterServer
}
public delegate void ExecuteAction(IAction inAction,
ref IAction outAction);
所以,如果我想在服务器上执行 getFile 命令,我需要一个函数:
correctAction = GetExecuteFunction(ExecuteTime.OnServer, "getFile");
我们现在需要的就是一些特性(attribute)来识别某个可选模块中的正确类:
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class TCPClientPluginAttribute : Attribute
{
}
[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = true)]
public sealed class TCPServerPluginAttribute : Attribute
{
}
TCP 服务器
好了,到目前为止,除了核心部分,也就是 TCP 服务器和 TCP 客户端,我们已经准备好了一切。你可以在互联网上找到一些 TCP 服务器的例子。而这一个也不例外。它只是一个经典的 TCP 服务器,接受客户端并为它们服务。主函数只是一个循环,用于接受客户端并处理它们的消息。
private void ListenForClients()
{
this.tcpListener.Start();
try
{
while (true && !this.stopWorking)
{
TcpClient client = this.tcpListener.AcceptTcpClient();
JobExecuter executer =
new JobExecuter(this, client, this.doEndConnection);
Thread clientThread =
new Thread(new ThreadStart(executer.HandleClient));
clientThread.Start();
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(ex.Message);
}
}
从代码中可以看出,有趣的部分隐藏在 HandleClient
方法中,这个方法也相当简单明了。
public void HandleClient()
{
NetworkStream clientStream = this.client.GetStream();
try
{
try
{
EventArgs e = new EventArgs();
while (!this.endConnectionEvent(this, e))
{
try
{
this.client.Client.SetSocketOption(SocketOptionLevel.Socket,
SocketOptionName.ReceiveTimeout, 5 * 1000);
this.client.Client.Blocking = true;
IMessage inMessage = new Message(clientStream);
IMessage outMessage = inMessage.Execute(ExecuteTime.OnServer);
outMessage.SendToStream(clientStream);
}
catch (Exception ex)
{
if (ex.InnerException is SocketException)
{
SocketException se = ex.InnerException as SocketException;
if (se.ErrorCode != 10060)
throw;
}
else
throw;
}
}
}
catch (Exception)
{
}
}
finally
{
this.client.Close();
}
}
该函数只是一个大循环,只要连接保持活动状态就会一直循环。所有的工作都在三行代码中完成:
- 获取消息
- 执行(处理)消息
- 将消息发回客户端
其余的代码是由于 TCP 协议的需要而存在的。前两行将套接字设置为阻塞模式,并将超时设置为 5 秒。阻塞模式比非阻塞模式更容易理解和编程。当然,唯一的缺点就是“阻塞”这个词,这意味着会话会阻塞程序的其余部分。但是,正如你在许多出版物中可以读到的,通过使用线程和超时,这个缺点很容易克服。回到代码。如上所述,前两行设置了套接字,而异常部分只是检查是否发生了超时异常(10060)。
TCP 客户端
客户端负责建立连接并执行服务器前和服务器后操作。
public static IMessage Execute(string ip, int port, IMessage jobs)
{
IMessage first = jobs.Execute(ExecuteTime.BeforeServer);
IMessage second = ExecuteTCP(ip, port, first);
IMessage third = second.Execute(ExecuteTime.AfterServer);
return third;
}
private static IMessage ExecuteTCP(string ip, int port, IMessage message)
{
try
{
NetworkStream stream = Connection.Inst(ip, port).Stream;
message.SendToStream(stream);
return new Message(stream);
}
catch (Exception ex)
{
if (ex.InnerException is SocketException)
{
SocketException socEx = ex.InnerException as SocketException;
if (socEx.ErrorCode == 10053)
{
if (Connection.Inst(ip, port).TryReconect())
return ExecuteTCP(ip, port, message);
}
}
return Message.ErrorMessage(ex.Message);
}
}
编写插件
为了让这个工具真正有用,额外的知识被隐藏在插件中,本节将向您展示如何编写一个插件。如上所述,有两种插件:一种用于服务器,另一种用于客户端。从程序员的角度来看,它们是相同的,类结构、消息、辅助函数……一切都一样,嗯,除了系统用来区分它们的特性(attribute)以及它们必须提供的已实现函数的数量。在服务器端,我们只需要为每个操作提供一个函数,而在客户端,我们需要两个(服务器前和服务器后)。下面,你可以看到 getFile 操作的实现。
TCPServer 插件示例
[TCPServerPluginAttribute]
public class FileSystem : Plugin
{
public override IBasicPluginData Description()
{
return new BasicPluginData("File System Server",
"1.0 beta", "Matjaz Prtenjak");
}
public FileSystem()
{
addServerAction("getFile", this.ExecuteGetFile);
}
public void ExecuteGetFile(IAction action, ref IAction outAction)
{
string inFile = CommonUtils.GetAttribute(action.Job,
"inFile") ?? string.Empty;
if (inFile.Length == 0) throw new Exception("inFile not specified");
if (!File.Exists(inFile))
throw new Exception(string.Format("{0} does not exists", inFile));
bool compress = CommonUtils.IsAttrSet(action.Job, "compress");
outAction.Segment = new Segment(inFile, compress);
}
}
我们定义了 FileSystem
类,它是基类插件的一个扩展。通过使用 TCPServerPluginAttribute
,我们将其标记为服务器端插件。首先,我们需要重写一个方法描述,它返回插件的简短描述;然后,我们定义构造函数,并在其中指定该插件能够执行哪个操作以及由哪个方法来完成这项工作。在我们的例子中,我们定义了 getFile 操作,负责它的方法是 ExecuteGetFile
。
在 ExecuteGetFile
方法中,我们首先搜索 'inFile
' 属性,它指定了我们想要获取的文件的名称。如果没有找到,则会抛出异常,如果指定的文件不存在,也会发生同样的情况。之后,我们只需在下一个分段中读取文件内容,并根据用户的意愿压缩数据。
TCPClient 插件示例
[TCPClientPluginAttribute]
public class FileSystem : Plugin
{
public override IBasicPluginData Description()
{
return new BasicPluginData("File System Client",
"1.0 beta", "Matjaž Prtenjak");
}
public FileSystem()
{
addClientAction("getFile", this. , this.ExecuteGetFileAfter);
addClientAction("deleteFile", Action.NoAction, Action.NoAction);
}
public void ExecuteGetFileBefore(IAction action, ref IAction outAction)
{
string outFile = CommonUtils.GetAttribute(
action.Job, "outFile") ?? string.Empty;
if ((outFile.Length != 0) &&
(CommonUtils.IsAttrSet(action.Job, "createOnly")))
{
if (File.Exists(outFile))
throw new Exception(string.Format("{0} already exists", outFile));
}
}
public void ExecuteGetFileAfter(IAction action, ref IAction outAction)
{
string outFile = CommonUtils.GetAttribute(action.Job, "outFile") ??
string.Empty;
if (outFile.Length != 0)
{
action.Segment.SaveToFile(outFile,
CommonUtils.IsAttrSet(action.Job, "createOnly"));
action.Segment = null;
CommonUtils.AddElement(outAction.Job, "value", "OK");
}
}
}
同样,FileSystem
扩展了 Plugin
并将自己标记为客户端插件。Description
方法返回有关插件的基本信息。构造函数为两个操作(第二个 'deleteFile' 在这里只是一个例子)定义了元素。第一个操作——我们感兴趣的操作 getFile——使用 ExecuteGetFileBefore
作为将在服务器之前执行的方法,以及 ExecuteGetFileAfter
作为将在服务器之后执行的方法。在这里,你还可以看到 deleteFile 操作的定义,它在客户端没有任何操作——不需要在服务器之前或之后执行任何代码。
在我们向服务器发送 getFile 操作之前,我们需要检查用户是否指定了 outFile 作为本地系统上的文件,该文件将保存来自移动设备的文件。如果指定了 outFile,并且用户还指定了只能创建新文件,但该文件已经存在,则会抛出异常。在服务器完成其部分工作后,我们只需(再次)检查是否存在 outFile
属性,如果找到它,我们就将数据保存到这个文件中。
程序
外部库
为了实现压缩,程序使用了免费、公开可用的库 SharpZipLib,但你不需要去它的网站,因为该库已经包含在源代码中。
源代码
源代码被组织成两个解决方案。一个用于 PC 的 .NET 2.0 C# 解决方案(TCP_WIN_APP),以及一个用于移动设备的 .NET CF 2.0 C# 解决方案(TCP_CF_APP)。
在 PC 端,你会得到一个 TCP 服务器和 TCP 客户端;在移动端,你(开箱即用)只会得到一个 TCP 服务器。
TCP_WIN_APP - PC 端
PluginManager 是主模块,包含了服务器和客户端都使用的核心函数。TCPClient 是一个实现了 TCP 客户端的模块,可以被不同的终端用户程序使用。TCPClientCMD 和 TCPClientWIN 就是这样的终端用户程序,顾名思义,前者用于命令行模式,后者是一个经典的 Windows 应用程序。TCPServer 当然就是 TCP 服务器。
除了这五个项目,还有两个插件向您展示编写插件是多么容易。这两个插件各有两种版本——一种用于服务器,一种用于客户端。因此,有 FileSystemClient 和 FileSystemServer 项目,它们实现了基本的文件系统功能。另外两个是 RemoteDesktopClient 和 RemoteDesktopServer,它们实现了基本的远程桌面功能。
TCP_CF_APP - 移动端
由于代码的编写方式使得 CF 端不需要任何更改,因此 CF 解决方案中的所有项目都使用了指向 .NET PC 端源代码的链接。而且,由于我从未需要移动设备作为客户端,我没有为移动设备实现任何客户端软件。因此,在移动端,你会得到 PluginManager、TCPServer、FileSystemServer 和 RemoteDesktpServer,它们都与 PC 端的相应部分功能相同。
可执行程序
可执行文件的编写方式是,所有主要内容都放在一个文件夹中,而所有插件都放在一个名为 plugins 的子文件夹中。所有插件在程序启动时会自动加载。如果在程序运行时添加了新插件,可以根据用户请求重新加载插件。
TCPServer 可执行文件在 PC 和移动端都是完整的。
我的主要用途是通过 TCP 命令行客户端,所以这个在 PC 端也是完整的。使用这个命令行工具很简单。你所要做的就是准备一个 XML 文件,其中包含你想在服务器上执行的操作(你可以在文章的“消息内容详解”部分看到这类 XML 文件的示例)。
但在 PC 端,你还会得到一个 Windows 程序。这部分的动机来自于,有时我需要看到移动设备的屏幕,通过 Windows 程序比将图片保存在文件中再用外部图片查看软件显示要容易得多。所有其他功能都已实现,但真的只是为了向你展示如何去做。你可以在服务器上浏览文件系统,并且可以创建和删除文件夹(右键单击!)。你还可以在服务器上复制文件(通过将文件从资源管理器拖到这个程序的右侧),也可以通过在程序中选择文件并使用右键菜单下载,将文件从服务器下载到 PC。如你所见,上传是通过拖放实现的,而下载是通过选择文件并使用菜单命令“下载”实现的。所以,你可以看到这个程序需要一些改进。
通过研究代码你能学到什么?
通过研究代码,你可以学到不少东西:
- 如何编写简单但可用的 TCP 服务器
- 如何编写简单但可用的 TCP 客户端
- 如何在客户端和服务器之间进行通信
- 如何为你的程序添加插件
- 如何为你的程序添加压缩/解压缩功能(使用 SharpZipLib 库)
- 等等。
示例中实现的操作
正如您在文章中读到的,每个操作都是一个 XML 标签。因此,它可以有不同的必需或可选参数。这意味着 action
属性是必需的(它定义了操作)。另一方面,还有一个 info
属性,它是可选的,通过设置 info
属性,用户可以获得有关服务器和客户端执行该操作所需时间的数据。
除非另有说明,否则属性可以通过将其值设置为 '1', 'YES', 'OK', 'TRUE', 或 'DA'('DA' 是斯洛文尼亚语中的“是”)来“设置”;任何其他值或属性的缺失都意味着“未设置”。
一些操作可以有一个 compress
属性,这意味着操作返回的数据在通过线路(空中)发送时将被压缩,并在客户端(或服务器,如果客户端发送压缩数据)自动解压缩。
示例中实现的操作如下:
<job action='getPluginsData'
info='0'/>
返回所有已加载插件的简要信息。
<job action='reloadPlugins'
info='0'/>
重新加载所有服务器和客户端插件。
<job action='dir'
folder='<requred>'
info='0'
foldersOnly='0'
recursive='0'
compress='0'
filePattern='*.*'/>
显示文件夹中的文件和子文件夹列表(foldersOnly
- 仅列出文件夹,recursive
- 也列出所有子文件夹,compress
- 压缩数据(如果超过 1 KB,数据会自动压缩),filePattern
- 所需的文件类型)。
<job action='getFile'
inFile='<requred>'
info='0'
outFile=''
compress='0'
createOnly='0'/>
从服务器返回一个文件(inFile
- 源文件(在服务器上),outFile
- 目标文件(在客户端上),createOnly
- 只有当 outFile 尚不存在时,操作才会成功)。
<job action='putFile'
inFile='c:\test\test.xml'
outFile='c:\druga.txt'
info='0'
compress='0'
createOnly='0'/>
与 getFile 相同,只是方向相反(从客户端到服务器)。
<job action='deleteFile'
file='c:\test\test.xml'
info='0'/>
从服务器删除文件。
<job action='createFolder'
folder='c:\test'
info='0'/>
在服务器上创建一个文件夹。
<job action='removeFolder'
folder='c:\test'
info='0'
recursive='0'/>
从服务器删除文件夹。
<job action='capture'
info='0'
format='bmp'
compress='0'
outFile='c:\picture.bmp'
createOnly='0'/>
捕获服务器屏幕(格式 - 'BMP', 'JPG', 'GIF', 或 'PNG')。
<job action='mouseClick'
x='1000'
y='3456'
info='0'
duration='500'
dblClick='0'/>
在所需坐标上执行鼠标单击(或双击)。坐标位于一个大小为 65535 x 65535 的虚拟画布上,因此实际坐标需要转换到这个虚拟画布上!
<job action='sendKeys'
keys='123xyz'
info='0'/>
向服务器上当前运行的应用程序发送按键。
扩展功能
您可以通过编写自定义插件来扩展功能。