RDP通道(远程桌面插件)开发指南—Delphi版

2023-01-05 admin

Windows的远程桌面服务,为了方便第三方开发者,对外提供了插件功能,也即RDP虚拟通道(VirtualChannels)接口。 RDP虚拟通道由两部分组成:客户端和服务端,其中客户端通道一般写成DLL形式,然后写入注册表登记,当用户运行远程桌面客户端的时候,就会自动加载它;服务端通道一般写成一个EXE,用户登录后运行它,它将会和客户端的DLL进行通信。RDP虚拟通道分为静态通道(StaticVirtualChannels)和动态通道(DynamicVirtualChannels)两种,下面分别介绍。

一、静态通道(StaticVirtualChannels)

(一)客户端

1、客户端DLL文件的注册

必须将客户端 DLL 的名称存储在注册表中。在注册表中,将子项添加到以下位置之一:

HKEY_CURRENT_USER\Software\Microsoft\Terminal Server Client\Default\Addins
HKEY_CURRENT_USER\Software\Microsoft\Terminal Server Client\connection\Addins

\Default\Addins项下的条目适用于所有连接。\connection\Addins项下的条目仅适用于由 connection 标识的连接。可以使用连接管理器创建和管理连接。可以为子项指定任何名称。它必须包含REG_SZ或REG_EXPAND_SZ值,并且可以选择包含REG_DWORD值。

REG_SZ或REG_EXPAND_SZ值的语法如下:

Name = DLLname

如果Name是一个REG_EXPAND_SZ值,它可以包含在运行时扩展的未扩展环境变量。DLLname的值可以是完全限定路径。如果DLLname不包含路径,则使用标准 DLL 搜索策略。有关详细信息,请参阅LoadLibrary的备注部分。

REG_DWORD值的原型如下:

RemoteControlPersistent = flag

标志的值可以是1或0。0是默认值。如果设置为 1,则在客户端会话启动或停止时不会通知服务应用程序。如果设置为0,则在客户端会话开始时发出 RECONNECT事件信号,并在客户端会话停止时发出 DISCONNECT 事件信号。

下面是一个从注册表导出的例子:

Windows Registry Editor Version 5.00

[HKEY_CURRENT_USER\Software\Microsoft\Terminal Server Client\Default\AddIns\Test1]
“Name”=”C:\\SVCClient.dll”

2、客户端DLL的编写

2.1 初始化

客户端Dll必须对外提供一个函数,名称为VirtualChannelEntry。函数定义如下:


function VirtualChannelEntry({[in]}pEntryPoints: PCHANNEL_ENTRY_POINTS): BOOL; stdcall;

远程桌面客户端加载这个DLL后,会先调用这个函数,并传递一个指向 CHANNEL_ENTRY_POINTS 结构的指针,该结构包含指向客户端虚拟通道函数的指针。该指针在VirtualChannelEntry函数返回后不再有效 。所以必须在这里复制一个备份,后面的函数会使用到。
VirtualChannelEntry实现必须调用 VirtualChannelInit 函数来初始化对虚拟通道的访问。
如果这个函数返回TRUE ,说明成功。如果发生错误,则返回FALSE 。在这种情况下,远程桌面服务将卸载这个DLL。

我们来看一下PCHANNEL_ENTRY_POINTS这个结构的定义:

type
tagCHANNEL_ENTRY_POINTS = record
cbSize: DWORD;//此结构的大小(以字节为单位)。
protocolVersion: DWORD;//协议版本。远程桌面服务将此成员设置为VIRTUAL_CHANNEL_VERSION_WIN2000。
pVirtualChannelInit: VIRTUALCHANNELINIT; //PVIRTUALCHANNELINIT;//指向 VirtualChannelInit函数的指针。
pVirtualChannelOpen: VIRTUALCHANNELOPEN; //PVIRTUALCHANNELOPEN;//指向 VirtualChannelOpen函数的指针
pVirtualChannelClose: VIRTUALCHANNELCLOSE; //PVIRTUALCHANNELCLOSE;//指向 VirtualChannelClose函数的指针。
pVirtualChannelWrite: VIRTUALCHANNELWRITE; //PVIRTUALCHANNELWRITE;//指向 VirtualChannelWrite函数的指针。
end;
CHANNEL_ENTRY_POINTS = tagCHANNEL_ENTRY_POINTS;
PCHANNEL_ENTRY_POINTS = ^tagCHANNEL_ENTRY_POINTS;

因为这里我们必须先调用一次 VirtualChannelInit 函数来初始化对虚拟通道的访问,所以看一下这个函数的定义:

type
VIRTUALCHANNELINIT = function(
{[in]}ppInitHandle: PPointer;//指向接收标识客户端连接的句柄的变量的指针。使用此句柄在对VirtualChannelOpen函数的后续调用中识别客户端 。
{[in, out]}pChannel: PCHANNEL_DEF;//指向CHANNEL_DEF 结构数组的指针。每个结构包含客户端 DLL 将打开的虚拟通道的名称和初始化选项。
//请注意,VirtualChannelInit调用不会打开这些虚拟通道;它仅保留此应用程序使用的名称。
{[in]}channelCount: Integer;//指定pChannel数组中的条目数。
{[in]}versionRequested: ULONG;//指定虚拟通道支持的级别。将此参数设置为VIRTUAL_CHANNEL_VERSION_WIN2000。
{[in]}pChannelInitEventProc: PCHANNEL_INIT_EVENT_FN//指向应用程序定义的 VirtualChannelInitEvent函数的指针,远程桌面
//服务调用该函数以将虚拟通道事件通知客户端 DLL。
): UINT; stdcall;

这个函数的作用,是初始化客户端 DLL 对远程桌面服务虚拟通道的访问。客户端调用VirtualChannelInit来注册其虚拟通道的名称。
只能从 VirtualChannelEntry 函数调用 VirtualChannelInit函数。在任何其他时间调用VirtualChannelInit都会失败。
如果函数成功,返回值为CHANNEL_RC_OK。
当VirtualChannelInit成功返回时,远程桌面服务已注册请求的频道。但是,远程桌面服务可能还没有完成其他初始化。当所有初始化完成后,远程桌面服务 使用CHANNEL_EVENT_INITIALIZED事件调用你的VirtualChannelInitEvent 回调函数。在调用此函数之前,你不应该对可用虚拟通道的数量进行假设,因为系统和其他插件可能已经预留了虚拟通道。因此,应该在调用此函数后始终检查CHANNEL_RC_TOO_MANY_CHANNELS返回码。当VirtualChannelInit返回时,如果通道已成功初始化,则每个CHANNEL_DEF结构 的选项成员
包括CHANNEL_OPTION_INITIALIZED 。
每个客户端会话的最大通道数是CHANNEL_MAX_COUNT( 30)。

再来看一下CHANNEL_DEF的定义:


type
tagCHANNEL_DEF = packed record
name: array[0..CHANNEL_NAME_LEN] of AnsiChar;//包含虚拟通道名称的以 Null 结尾的字符串。虚拟频道名称可以包含 1 到 CHANNEL_NAME_LEN 个字符。
options: ULONG;//指定此虚拟通道的选项
end;

拟通道的选项,下表显示了可以组合使用的可能值:

CHANNEL_OPTION_ENCRYPT_CS 加密客户端到服务器的数据。
CHANNEL_OPTION_ENCRYPT_RDP 根据远程桌面协议 (RDP) 数据加密进行加密。也就是说,如果 RDP 数据是加密的,那么也对该通道进行加密。
CHANNEL_OPTION_INITIALIZED 通道被初始化。该值由VirtualChannelInit或VirtualChannelInitEx函数设置。
CHANNEL_OPTION_REMOTE_CONTROL_PERSISTENT 该通道被声明为远程控制持久。这意味着当一个会话开始隐藏当前会话时,或者当远程控制连接到连接到客户端的会话或从连接到客户端的会话断开连接时,通道不会在服务器端关闭。有关详细信息,请参阅远程控制持久虚拟通道。
CHANNEL_OPTION_SHOW_PROTOCOL 影响服务器端接收VirtualChannelWrite发送的数据的方式。如果设置了这个值,每个数据块前面都有一个 CHANNEL_PDU_HEADER 结构。如果未设置此值,则数据块仅包含指定给VirtualChannelWrite的数据。

下面是一个典型的例子:


var
//全局变量定义
g_EntryPoints: CHANNEL_ENTRY_POINTS;
g_pInitHandle: Pointer;

const
pszChannelName='MyTestChannel';//通道的名称,必须跟服务端一致

function VirtualChannelEntry({[in]}pEntryPoints: PCHANNEL_ENTRY_POINTS): BOOL; stdcall;
var
Channel: CHANNEL_DEF;
ulRet: UINT;
begin
g_EntryPoints := pEntryPoints^; //先保存指针

FillChar(Channel, sizeof(Channel), 0);
lstrcpyA(Channel.name, pszChannelName);

ulRet := g_EntryPoints.pVirtualChannelInit(@g_pInitHandle, @Channel, 1, VIRTUAL_CHANNEL_VERSION_WIN2000, @VirtualChannelInitEvent);
Result := ulRet = CHANNEL_RC_OK;
if not Result then Exit;
if (Channel.options and CHANNEL_OPTION_INITIALIZED) = 0 then Result := False;
end;

因为一个插件本身可以支持多个通道,我们将它修改一下,变成多个通道:

const
DEFChannelsCount = 2;
var
g_EntryPoints: CHANNEL_ENTRY_POINTS;
g_pInitHandle: Pointer;
g_ChannelDef: array[0..DEFChannelsCount - 1] of CHANNEL_DEF =
(
(name: 'MyTest1'; options: 0),
(name: 'MyTest2'; options: 0)
);

function VirtualChannelEntry({[in]}pEntryPoints: PCHANNEL_ENTRY_POINTS): BOOL; stdcall;
var
ulRet: UINT;
i: Integer;
dwID: DWORD;
begin
g_EntryPoints := pEntryPoints^;//先保存指针

ulRet := g_EntryPoints.pVirtualChannelInit(@g_pInitHandle, @g_ChannelDef[0], DEFChannelsCount, VIRTUAL_CHANNEL_VERSION_WIN2000, @VirtualChannelInitEvent);
Result := ulRet = CHANNEL_RC_OK;
if Result then
begin
for i := 0 to DEFChannelsCount - 1 do
if (g_ChannelDef[i].options and CHANNEL_OPTION_INITIALIZED) = 0 then
begin
Result := False;
Exit;
end;
end;
end;

2.2 虚拟通道事件

我们在上一节的初始化函数里面传递了一个自定义回调函数VirtualChannelInitEvent,这个函数的原型为:


type
CHANNEL_INIT_EVENT_FN = procedure(
pInitHandle: Pointer;//处理客户端连接。这是在VirtualChannelInit函数的ppInitHandle参数中 返回的句柄。
event: UINT;//指示导致通知的事件 。
pData: Pointer;//指向事件的附加数据的指针。数据类型取决于事件 。
dataLength: UINT//指定pData缓冲区中数据的大小(以字节为单位) 。
); stdcall;

其中事件的参数可以是以下值之一:

CHANNEL_EVENT_INITIALIZED (0):远程桌面连接 (RDC) 客户端初始化已完成。pData参数为NULL。
CHANNEL_EVENT_CONNECTED (1):已与支持虚拟通道的 RD 会话主机服务器建立连接。pData参数是指向具有服务器名称的空终止字符串的 指针。
CHANNEL_EVENT_V1_CONNECTED (2):已与不支持虚拟通道的 RD 会话主机服务器建立连接。pData参数为NULL。
CHANNEL_EVENT_DISCONNECTED (3):与 RD 会话主机服务器的连接已断开。pData参数为NULL。
CHANNEL_EVENT_TERMINATED (4):客户端已终止。pData参数为NULL。
CHANNEL_EVENT_REMOTE_CONTROL_START (5):远程控制操作已经开始。pData参数为NULL。
CHANNEL_EVENT_REMOTE_CONTROL_STOP (6):远程控制操作已终止。pData参数是指向 包含服务器名称的空终止字符串的指针。

其中我们关心的是CHANNEL_EVENT_CONNECTED和CHANNEL_EVENT_TERMINATED事件。在CHANNEL_EVENT_CONNECTED事件中,我们需要
打开我们的通道、关联收发数据回调,而在CHANNEL_EVENT_TERMINATED事件里面释放我们需要释放的资源。

因为我们的插件存在多个通道,每个通道有一个句柄,所以先定义一个结构:

type
TChannelRecord = record
Handle: DWORD;//不管32位还是64位,这个都是DWORD,其实是一个标识符而不是传统意义上的句柄
InputBuffer: PAnsiChar;
InputBufferSize: Cardinal;
InputBufferOffset: Cardinal;
OutputBuffer: PAnsiChar;
OutputBufferSize: Cardinal;
OutputBufferOffset: Cardinal;
//procedure Write;
end;

结构的一些参数我们后面用到的时候再说明。再定义一个全局数组:

var
g_ChannelRecord: array[0..DEFChannelsCount - 1] of TChannelRecord;

另外,为了直观操作,我们加入了一个VCL窗口,名称为FrmMain,目前只有一个函数用于输出信息:procedure ShowInfo(const strInfo: string);

现在,可以编写我们的回调函数了:

procedure VirtualChannelInitEvent(
pInitHandle: Pointer;
event: UINT;
pData: Pointer;
dataLength: UINT); stdcall;
var
i: Integer;
ulRet: UINT;
begin
case event of
CHANNEL_EVENT_INITIALIZED:
begin
FrmMain.ShowInfo('initialized!');
end;
CHANNEL_EVENT_CONNECTED:
begin
FrmMain.ShowInfo('connected!');
for i := 0 to DEFChannelsCount - 1 do
begin
ulRet := g_EntryPoints.pVirtualChannelOpen(pInitHandle, @g_ChannelRecord[i].Handle, g_ChannelDef[i].name, @VirtualChannelOpenEvent);
if ulRet <> CHANNEL_RC_OK then FrmMain.ShowInfo('VirtualChannelOpen Error!');
end;
end;
CHANNEL_EVENT_V1_CONNECTED:
begin
FrmMain.ShowInfo('v1 connected!');
end;
CHANNEL_EVENT_DISCONNECTED:
begin
FrmMain.ShowInfo('desconnected!');
end;
CHANNEL_EVENT_TERMINATED:
begin
FrmMain.ShowInfo('terminated!');
for i := 0 to DEFChannelsCount - 1 do
begin
g_EntryPoints.pVirtualChannelClose(g_ChannelRecord[i].Handle);
end;
end;
else
begin
FrmMain.ShowInfo('unknow event1!');
end;
end;
end;

在CHANNEL_EVENT_CONNECTED事件里面,我们调用函数VirtualChannelOpen打开虚拟通道的客户端。该函数原型为:

type
VIRTUALCHANNELOPEN = function(
{[in]}pInitHandle: Pointer;//处理客户端连接。这是在VirtualChannelInit函数的ppInitHandle参数中 返回的句柄。
{[out]}pOpenHandle: PDWORD;//指向一个变量的指针,该变量接收一个句柄,该句柄在随后调用 VirtualChannelWrite和 VirtualChannelClose函数时标识打开的虚拟通道。
{[in]}pChannelName: PAnsiChar;//指向包含要打开的虚拟通道名称的以 null 结尾的 ANSI 字符串的指针。该名称必须在客户端调用 VirtualChannelInit函数时注册。
{[in]}pChannelOpenEventProc: PCHANNEL_OPEN_EVENT_FN//指向应用程序定义的 VirtualChannelOpenEvent函数的指针,远程桌面服务调用该函数以将此虚拟通道的事件通知客户端 DLL。
): UINT; stdcall;

如果函数成功,返回值为 CHANNEL_RC_OK。

再来看最后一个参数 VirtualChannelOpenEvent函数的原型:

type
CHANNEL_OPEN_EVENT_FN = procedure(
{[in]}openHandle: DWORD;//处理虚拟通道。这是VirtualChannelOpen 函数的pOpenHandle 参数中返回的句柄。
{[in]}event: UINT;//指示导致通知的事件。
{[in]}pData: Pointer;//指向事件的附加数据的指针。数据类型取决于事件。
{[in]}dataLength: UINT32;//指定pData缓冲区中数据的大小(以字节为单位) 。
{[in]}totalLength: UINT32;//指定通过单个写操作写入虚拟通道服务器端的数据的总大小(以字节为单位)。
{[in]}dataFlags: UINT32//提供有关在 CHANNEL_EVENT_DATA_RECEIVED事件中接收的数据块的信息。
); stdcall;

其中:
第二个参数event可以是以下值之一:

CHANNEL_EVENT_DATA_RECEIVED:虚拟通道从服务器端接收数据。pData是指向数据块的指针。dataLength指示此块的大小。 totalLength表示服务器写入数据的总大小。
CHANNEL_EVENT_WRITE_CANCELLED:由 VirtualChannelWrite调用启动的写操作已被取消。pData是在VirtualChannelWrite的pUserData 参数中指定的值。当客户端会话断开时,写操作被取消。此通知使你能够释放与写入操作关联的任何内存。
CHANNEL_EVENT_WRITE_COMPLETE:由 VirtualChannelWrite调用启动的写操作已经完成。pData是在 VirtualChannelWrite的pUserData参数中 指定的值。

第三个参数pData指向事件的附加数据的指针,数据类型取决于事件,如前面事件描述中所述:

如果事件是CHANNEL_EVENT_DATA_RECEIVED,则服务器写入的数据被分成不超过CHANNEL_CHUNK_LENGTH字节的块。dataFlags 参数指示当前块是在服务器写入的数据块的开头、中间还是结尾。请注意,此参数的大小可以大于dataLength参数指定的值。应用程序应该只读取dataLength指定的字节数。

最后一个参数dataFlags提供有关在 CHANNEL_EVENT_DATA_RECEIVED事件中接收的数据块的信息。将设置以下位标志:

CHANNEL_FLAG_FIRST:chunk 是单个写操作写入的数据的开始。比较此标志时使用按位比较。
CHANNEL_FLAG_LAST:chunk 是单次写操作写入的数据的结尾。比较此标志时使用按位比较。
CHANNEL_FLAG_MIDDLE:这是默认设置。该块位于单个写入操作写入的数据块的中间。不要使用按位比较直接比较这个标志值。相反,使用按位比较来确定标志值不是CHANNEL_FLAG_FIRST或
CHANNEL_FLAG_LAST。这是通过使用以下比较来完成的: Result := (flags and CHANNEL_FLAG_FIRST=0) and (flags and CHANNEL_FLAG_LAST=0)。
CHANNEL_FLAG_ONLY:组合CHANNEL_FLAG_FIRST和CHANNEL_FLAG_LAST 值。该块包含来自单个写操作的所有数据。比较此标志时使用按位比较。

我们的代码如下:

procedure VirtualChannelOpenEvent(
openHandle: DWORD;
event: UINT;
pData: Pointer;
dataLength: UINT32;
totalLength: UINT32;
dataFlags: UINT32); stdcall;
var
pChannelRecord: LPChannelRecord;
nIndex: Integer;
begin

//先查找是哪个通道的事件
pChannelRecord := nil;
for nIndex := 0 to DEFChannelsCount - 1 do
begin
if g_ChannelRecord[nIndex].Handle = openHandle then
begin
pChannelRecord := @g_ChannelRecord[nIndex];
Break;
end;
end;
if pChannelRecord = nil then
begin
FrmMain.ShowInfo('Can not Found ChannelRecord!');
Exit;
end;

case event of
CHANNEL_EVENT_DATA_RECEIVED:
begin
FrmMain.ShowInfo(Format('chaneel%d recv data!', [nIndex]));

if ((dataFlags and CHANNEL_FLAG_FIRST) <> 0) then//第一个数据包,分配接收缓冲区
begin
if pChannelRecord^.InputBuffer <> nil then FreeMemory(pChannelRecord^.InputBuffer);
pChannelRecord^.InputBuffer := GetMemory(totalLength);
pChannelRecord^.InputBufferOffset := 0;
pChannelRecord^.InputBufferSize := totalLength;
FrmMain.ShowInfo('Input Buffer allocated');
end;

if pChannelRecord^.InputBuffer = nil then
begin
FrmMain.ShowInfo('Internal error. No buffer allocated.');
Exit;
end;

if pChannelRecord^.InputBufferOffset + dataLength <= pChannelRecord^.InputBufferSize then
begin
Move(pData^, (pChannelRecord^.InputBuffer + pChannelRecord^.InputBufferOffset)^, dataLength);
Inc(pChannelRecord^.InputBufferOffset, dataLength);
end
else
begin
FrmMain.ShowInfo('Data received exceeds buffer size');
end;

if (dataFlags and CHANNEL_FLAG_LAST) <> 0 then//数据接收完毕
begin
FrmMain.ShowInfo(Format('chaneel%d Buffer complete!', [nIndex]));
ProcessRecvData(pChannelRecord);//处理过程
FreeMemory(pChannelRecord^.InputBuffer);
pChannelRecord^.InputBuffer := nil;
end;
end;

CHANNEL_EVENT_WRITE_COMPLETE:
begin
FrmMain.ShowInfo(Format('chaneel%d write compltet!', [nIndex]));
FreeMemory(pChannelRecord^.OutputBuffer);
pChannelRecord^.OutputBuffer := nil;
end;
CHANNEL_EVENT_WRITE_CANCELLED:
begin
FrmMain.ShowInfo(Format('chaneel%d write canceled!', [nIndex]));
FreeMemory(pChannelRecord^.OutputBuffer);
pChannelRecord^.OutputBuffer := nil;
end;
else
begin
FrmMain.ShowInfo(Format('chaneel%d unknow event2!', [nIndex]));
end;
end;
end;

其中数据处理过程里面,我们只是简单的将数据回发给服务端:

procedure ProcessRecvData(pChannelRecord: LPChannelRecord);
begin
SendDataToServer(pChannelRecord^.InputBuffer, pChannelRecord^.InputBufferSize, pChannelRecord);
end;

procedure SendDataToServer(pData: Pointer; dwSize: DWORD; pChannelRecord: LPChannelRecord);
var
ulRet: UINT;
begin
if pChannelRecord^.OutputBuffer <> nil then
begin
FrmMain.ShowInfo('The channel last time send data not COMPLETE!');
Exit;
end;
pChannelRecord^.OutputBuffer := GetMemory(dwSize);
CopyMemory(pChannelRecord^.OutputBuffer, pData, dwSize);
ulRet := g_EntryPoints.pVirtualChannelWrite(pChannelRecord^.Handle,
pChannelRecord^.OutputBuffer,
dwSize,
nil);
if ulRet <> CHANNEL_RC_OK then FrmMain.ShowInfo(Format('Write failed! Error = %d.!', [ulRet]));
end;

这里,数据发送的函数原型为:

type
VIRTUALCHANNELWRITE = function(
{[in]}openHandle: DWORD;//处理虚拟通道。这是VirtualChannelOpen函数的pOpenHandle参数中 返回的句柄。
{[in]}pData: Pointer;//指向包含要写入的数据的缓冲区的指针。
{[in]}dataLength: ULONG;//指定要写入的pData缓冲区中数据的字节数。
{[in]}pUserData: Pointer//应用程序定义的值。当写入操作完成或取消时,此值将传递给你的 VirtualChannelOpenEvent函数。
): UINT; stdcall;

如果函数成功,返回值为 CHANNEL_RC_OK。

VirtualChannelWrite函数是异步的 。写入操作完成后,你的 VirtualChannelOpenEvent函数会收到 CHANNEL_EVENT_WRITE_COMPLETE 通知。在收到该通知之前,调用者不得释放或重新使用传递给 VirtualChannelWrite的pData缓冲区。当写操作完成或取消时,为pUserData参数指定的值将传递给你的 VirtualChannelOpenEvent函数。你可以使用此数据来识别写操作。虚拟通道服务器端的服务器插件调用 WTSVirtualChannelRead函数来读取由 VirtualChannelWrite调用写入的数据。

3、服务端插件程序编写

客户端插件DLL编译、安装完毕后(需要注意远程客户端的位数,如果是64位系统,必须编译为64位的DLL),我们来编写服务端插件。服务端插件可以是用户应用层EXE形式,也可以是服务程序等,在客户端连接后,即可进行操作。

3.1 打开/连接客户端虚拟通道

打开客户端的虚拟通道使用函数WTSVirtualChannelOpen,函数原型为:

function WTSVirtualChannelOpen(
{[in]}hServer: THANDLE;//此参数必须是 WTS_CURRENT_SERVER_HANDLE
{[in]}SessionId: DWORD;//远程桌面服务会话标识符。要指示当前会话,请指定WTS_CURRENT_SESSION。你可以使用 WTSEnumerateSessions函数检索指定 RD 会话主机服务器上所有会话的标识符。
//要在另一个用户的会话中打开虚拟频道,需要获得虚拟频道的许可。有关详细信息,请参阅 远程桌面服务权限。要修改会话权限,请使用远程桌面服务配置管理工具。
{[in]}pVirtualName: PAnsiChar//指向包含虚拟通道名称的空终止字符串的指针。请注意,即使定义了 UNICODE,这也是一个 ANSI 字符串。虚拟频道名称由一到 CHANNEL_NAME_LEN 个字符组成,不包括终止null。
): THANDLE; stdcall;

如果函数成功,返回值是指定虚拟通道的句柄。如果函数失败,则返回值为NULL。要获取扩展的错误信息,请调用 GetLastError。

我们的代码如下(注意:我们的客户端存在多个通道,这里我们只打开第一个):

const
pszChannelName: PAnsiChar = 'MyTest1';//对应客户端的虚拟通道名称

procedure TFrmMain.Button1Click(Sender: TObject);
var
dwError: DWORD;
begin
m_hChannelHandle := WTSVirtualChannelOpen(
WTS_CURRENT_SERVER_HANDLE,
WTS_CURRENT_SESSION,
pszChannelName);

if m_hChannelHandle = 0 then
begin
dwError := GetLastError;
ShowMessage(Format('WTSVirtualChannelOpen Error!Code:%d;%s', [dwError, SysErrorMessage(dwError)]));
Exit;
end;
ShowMessage('WTSVirtualChannelOpen OK!');
end;

通道打开成功后,就可以收发数据了。其中发送数据的API原型为:

function WTSVirtualChannelWrite(
{[in]}hChannelHandle: THANDLE;//处理由WTSVirtualChannelOpen函数打开的虚拟通道 。
{[in]}Buffer: PAnsiChar;//指向包含要写入虚拟通道的数据的缓冲区的指针。
{[in]}Length: ULONG;//指定要写入的数据的大小(以字节为单位)。
{[out]}pBytesWritten: PULONG//指向接收写入字节数的变量的指针。
): BOOL; stdcall;

如果函数成功,则返回值为非零值。如果函数失败,则返回值为零。要获取扩展的错误信息,请调用 GetLastError。
注意:WTSVirtualChannelWrite不是线程安全的。要从多个线程访问虚拟通道,或通过虚拟通道执行异步 IO,请将WTSVirtualChannelQuery与 WTSVirtualFileHandle一起使用。具体例子请参阅MSDN的函数WTSVirtualChannelQuery里面的说明,或者参考后面动态通道例子代码。

我们的代码:

procedure TFrmMain.Button3Click(Sender: TObject);
var
szBuffer: array[0..1023] of AnsiChar;
ulBytesWritten: ULONG;
dwError: DWORD;
begin
lstrcpyA(szBuffer, PAnsiChar(AnsiString(Edit1.Text)));
if WTSVirtualChannelWrite(m_hChannelHandle, szBuffer, lstrlenA(szBuffer), @ulBytesWritten) then
begin
ShowMessage('WTSVirtualChannelWrite OK!');
end
else
begin
ShowMessage(Format('WTSVirtualChannelWrite Error!Code:%d;%s', [dwError, SysErrorMessage(dwError)]));
end;
end;

数据接收函数:

function WTSVirtualChannelRead(
{[in]}hChannelHandle: THANDLE;//处理由WTSVirtualChannelOpen函数打开的虚拟通道 。
{[in]}TimeOut: ULONG;//指定超时时间,以毫秒为单位。如果TimeOut为零, 则 WTSVirtualChannelRead在没有要读取的数据时立即返回。如果TimeOut是 INFINITE,函数将无限期等待,直到有数据要读取。
{[out]}Buffer: PAnsiChar;//指向接收从虚拟通道的服务器端读取的数据块的缓冲区的指针。
{[in]}BufferSize: ULONG;//指定Buffer的大小(以字节为单位)。如果Buffer中的数据块前面是CHANNEL_PDU_HEADER结构,则此参数的值应至少为 CHANNEL_PDU_LENGTH。否则,此参数的值应至少为CHANNEL_CHUNK_LENGTH。
{[out]}pBytesRead: PULONG//指向接收读取字节数的变量的指针。
): BOOL; stdcall;

其中第三个参数Buffer,需要注意的地方是:服务器在单个 WTSVirtualChannelRead调用中可以接收的最大数据量是 CHANNEL_CHUNK_LENGTH字节。如果客户端的 VirtualChannelWrite调用写入了更大的数据块,则服务器必须进行多次WTSVirtualChannelRead调用。另外, 在某些情况下,远程桌面服务 在WTSVirtualChannelRead函数读取的每个数据块的开头 放置一个
CHANNEL_PDU_HEADER结构。如果客户端 DLL在调用VirtualChannelInit函数初始化虚拟通道时设置了CHANNEL_OPTION_SHOW_PROTOCOL选项, 就会发生这种情况。如果通道是使用IWTSVirtualChannel::Write方法写入的动态虚拟通道,也会发生这种情况。否则,缓冲区仅接收在 VirtualChannelWrite调用中写入的数据。

如果函数成功,则返回值为非零值。如果函数失败,则返回值为零。要获取扩展的错误信息,请调用 GetLastError。
注意:WTSVirtualChannelRead不是线程安全的。要从多个线程访问虚拟通道,或通过虚拟通道执行异步 IO, 请将WTSVirtualChannelQuery与 WTSVirtualFileHandle一起使用。具体例子请参阅MSDN的函数WTSVirtualChannelQuery里面的说明,或者参考后面动态通道的例子代码。

我们的代码:

procedure TFrmMain.Button4Click(Sender: TObject);
var
szBuffer: array[0..1023] of AnsiChar;
ulBytesRead: ULONG;
dwError: DWORD;
begin
FillChar(szBuffer, sizeof(szBuffer), 0);
if (not WTSVirtualChannelRead(m_hChannelHandle,
1000 * 5, //INFINITE,
szBuffer,
sizeof(szBuffer),
@ulBytesRead)) then
begin
ShowMessage(Format('WTSVirtualChannelRead Error!Code:%d;%s', [dwError, SysErrorMessage(dwError)]));
end
else
begin
Memo1.Lines.Add(StrPas(szBuffer));
end;
end;

最后,在结束通道之前可以使用函数关闭通道,函数原型为:

function WTSVirtualChannelClose(
{[in]}hChannelHandle: THANDLE//处理由WTSVirtualChannelOpen函数打开的虚拟通道 。
): BOOL; stdcall;

如果函数成功,则返回值为非零值。如果函数失败,则返回值为零。要获取扩展的错误信息,请调用 GetLastError。

我们的代码:

procedure TFrmMain.Button2Click(Sender: TObject);
var
dwError: DWORD;
begin
if (not WTSVirtualChannelClose(m_hChannelHandle)) then
begin
dwError := GetLastError;
ShowMessage(Format('WTSVirtualChannelClose Error!Code:%d;%s', [dwError, SysErrorMessage(dwError)]));
Exit;
end;
m_hChannelHandle := 0;
ShowMessage('WTSVirtualChannelClose OK!');
end;

二、动态通道(DynamicVirtualChannels)

动态虚拟通道 (DVC) API 扩展了用于远程桌面服务的现有虚拟通道 API,称为静态虚拟通道 (SVC) API。DVC API 解决了客户端和服务器之间的 SVC API 中存在的几个限制,例如:频道数量有限、数据包重建等。另外,如果想在使用RDPClient Activex的程序内调用虚拟通道,也必须写成动态虚拟通道形式。

(一)客户端

和静态通道一样,目标DLL的位数也必须与操作系统的一致,否则远程桌面客户端不会加载它。另外,动态通道内部是通过COM形式实现的。

1、客户端DLL文件的注册

如果是使用RDPClient Activex的程序,则无需在注册表写入条目,而是调用远程桌面协议 (RDP) ActiveX 控件的IMsTscAdvancedSettings::put_PluginDlls方法。多个条目必须以逗号分隔。

如果是远程桌面客户端使用,则需要将插件条目写入启动远程桌面连接 (RDC) 客户端进程的计算机上的注册表中的以下位置:

HKEY_CURRENT_USER\Software\Microsoft\Terminal Server Client\Default\AddIns\唯一插件名称

在唯一插件名称下,你必须添加一个标识插件的条目。

条目名称 = Name
数据类型 = REG_SZ或REG_EXPAND_SZ

其中,条目值可以分为三种情况:

a、Plug-inDLLName:{CLSID}。例如:“C:\DVCClient.dll:{A2E28376-69FC-4705-ACB6-0923794C0AA2}”。
这种情况下,插件不一定作为组件对象模型 (COM) 对象在 Windows 注册表中注册,但 DLL 是作为进程内 COM 对象实现的。RDC 客户端将加载由Plug-inDLLName指定的 DLL,并直接使用CLSID检索 COM 对象。

b、Plug-inDLLName。例如:“C:\DVCClient.dll”。
这种情况下,DLL 实现VirtualChannelGetInstance函数并按名称导出它。RDC 客户端将使用VirtualChannelGetInstance函数为DLL 实现的所有插件获取IWTSPlugin接口指针。

c、{CLSID}。例如:“{A2E28376-69FC-4705-ACB6-0923794C0AA2}”。
这种情况下,RDC 客户端将使用带有CLSID的CoCreateInstance将插件实例化为常规 COM 对象。

Plug-inDLLName表示 .dll 文件的完整路径和文件名。如果数据类型为REG_EXPAND_SZ,则路径可以包含在运行时展开的未展开环境变量。

当远程桌面连接 (RDC) 客户端完成初始化时,它将为每个已注册的插件执行以下操作:

(1)使用上述方法之一为每个插件获取IWTSPlugin接口的实例。
(2)调用每个IWTSPlugin接口的Initialize方法。
(3)如果客户端多次连接到相同或不同的服务器,则可能会多次调用Connected和Disconnected方法。
(4)插件应处理的最后一个调用是Terminated。这是远程桌面连接 (RDC) 客户端即将卸载插件的信号。

下面是一个从注册表导出的例子:

Windows Registry Editor Version 5.00

[HKEY_CURRENT_USER\SOFTWARE\Microsoft\Terminal Server Client\Default\AddIns\DVCClient]
“Name”=”C:\\DVCClient.dll:{A2E28376-69FC-4705-ACB6-0923794C0AA2}”

2、客户端DLL的编写

2.1 初始化

为了满足上面任意一种的注册形式,我们的DLL将导出多个函数。和静态通道一样,我们也将使用一个VCL窗口,项目代码如下:

library DVCClient;

uses
Windows,
ComServ,
DVCClientUnit in 'DVCClientUnit.pas',
DVCClientInterface in 'DVCClientInterface.pas',
Unit_FrmMain in 'Unit_FrmMain.pas' {FrmMain};

exports
VirtualChannelGetInstance,
DllGetClassObject,
DllCanUnloadNow,
DllRegisterServer,
DllUnregisterServer;

{$R *.RES}

begin
DllProc := @DllMain;
DllMain(DLL_PROCESS_ATTACH);
IsMultiThread := True;
end.

注意:如果注册表指向是仅为{CLSID},则编译DLL后需要以管理员身份调用系统的regsvr32对DLL进行注册一次。

为了实现动态通道,我们必须继承并实现三个接口:

第一个接口IWTSPlugin声明如下:

type
IWTSPlugin = interface(IUnknown)
['{A1230201-1439-4e62-a414-190d0ac3d40e}']
function Initialize_({In}pChannelMgr: IWTSVirtualChannelManager): HResult; stdcall;//用于从客户端到插件的第一次调用。
function Connected(): HResult; stdcall;//通知插件远程桌面连接 (RDC) 客户端已成功连接到远程桌面会话主机(RD 会话主机)服务器。
function Disconnected(dwDisconnectCode: DWORD): HResult; stdcall;//通知插件远程桌面连接 (RDC) 客户端已与远程桌面会话主机(RD 会话主机)服务器断开连接。
function Terminated: HResult; stdcall;//通知插件远程桌面连接 (RDC) 客户端已终止。
end;

其中:

Initialize_方法:参数pChannelMgr,将实例传递给客户端的通道管理器 ( IWTSVirtualChannelManager )。如果调用成功完成,则返回S_OK 。如果调用失败,插件将由远程桌面连接 (RDC) 客户端释放。
Connected方法:如果调用成功完成,则返回S_OK 。如果调用失败返回E_FAIL,但插件将继续工作。
Disconnected方法:参数dwDisconnectCode标识断开连接原因的代码。有关可能的代码,请参阅IMsTscAxEvents::OnDisconnected。如果调用成功完成,则返回S_OK 。如果调用失败,则不执行任何操作。
Terminated方法:通知插件远程桌面连接 (RDC) 客户端已终止。在调用IWTSPlugin::Terminated之后,预计不会再调用该插件。任何插件清理都应该在这里完成。如果调用成功完成,则返回S_OK 。如果调用失败,则不执行任何操作。
作为 COM 对象,插件必须在自由线程模型(free-threading mode)中实现。因为IWTSPlugin方法是由插件实现的,所以插件必须知道调用可能到达不同的线程。调用总是串行到达,因此不可能有任何两个并行执行的调用。
IWTSPlugin接口由 %System32%\webauthn.dll 实现,以启用远程桌面 WebAuthn 重定向功能。通过调用VirtualChannelGetInstance获取该接口的一个实例,它也是由 webauthn.dll 提供的。

第二个接口如下:

type
IWTSListenerCallback = interface(IUnknown)
['{A1230203-d6a7-11d8-b9fd-000bdbd1f198}']
function OnNewChannelConnection(
const {In}pChannel: IWTSVirtualChannel;
{in}data: BSTR;
{out}pbAccept: PBOOL;
{out}out ppCallback: IWTSVirtualChannelCallback
): HResult; stdcall;//允许远程桌面连接 (RDC) 客户端插件接受或拒绝传入连接的连接请求。
end;

这个接口主要用于通知远程桌面连接 (RDC) 客户端插件有关特定侦听器的传入请求,只有一个方法,参数说明如下:

pChannel:表示传入连接的IWTSVirtualChannel对象。仅当此方法接受连接时,才会连接此对象。
data:此参数未实现,保留供将来使用
pbAccept:指示是否应接受连接。如果应该接受连接则 pbAccept设置为TRUE ,否则设置为FALSE。
ppCallback:接收 IWTSVirtualChannelCallback对象,该对象接收连接通知。该对象由插件创建。

如果此方法成功,则返回S_OK。否则,它返回一个HRESULT错误代码。

第三个接口如下:

type
IWTSVirtualChannelCallback = interface(IUnknown)
['{A1230204-d6a7-11d8-b9fd-000bdbd1f198}']
function OnDataReceived(
{In}cbSize: ULONG;
{in}pBuffer: PBYTE
): HResult; stdcall;//通知用户有关正在接收的数据。
function OnClose: HResult; stdcall;//通知用户通道已关闭。
end;

这个接口主要用于接收有关通道状态更改或接收到的数据的通知。该接口由用户实现。此接口的每个实例都与IWTSVirtualChannel的一个实例相关联。此接口的实现不应阻止这些调用,因为这可能会抑制其他回调。不能保证这些调用总是到达同一个线程,即使对于插件的进程内 COM 实现也是如此。在这些回调中允许调用IWTSVirtualChannel的Write和Close方法。

其中:
OnDataReceived方法的参数:
cbSize:接收数据的缓冲区的大小(以字节为单位)。
pBuffer:指向接收数据的缓冲区的指针。此缓冲区仅在此调用完成之前有效。
成功返回S_OK。如果调用失败,则不执行任何操作。

OnClose方法:通知用户通道已关闭。关闭通道的方式有以下三种:

1、用户调用了 IWTSVirtualChannel::Close方法。
2、远程桌面连接 (RDC) 客户端已与远程桌面会话主机(RD 会话主机)服务器断开连接。
3、服务器已调用通道上的 WTSVirtualChannel::Close方法。

无论通道如何关闭,收到此调用时都无需调用 IWTSVirtualChannel::Close()。如果进行了这样的调用,那么如果插件正在用完进程,那么对 IWTSVirtualChannel::Close()的调用
可能会导致死锁。可能会发生死锁,因为 OnClose()的调用者持有频道列表锁,而Close()方法将尝试在不同的线程上获取相同的锁。

可能大家对这几个接口一时之间觉得很懵逼,其实简单来说,就是三步:

第一步:远程桌面客户端初始化插件,调用插件TTDVCClient接口的Initialize_方法。这个时候,插件自己创建一个TWTSListenerCallback的实例a,并调用传递进来的pChannelMgr
接口注册这个实例a。

第二步:当服务端插件运行并打开这个通道的时候,会触发第一步创建的TWTSListenerCallback实例a里面的OnNewChannelConnection事件。这个时候,插件自己创建一个
TWTSVirtualChannelCallback的实例b,将参数pChannel给它保存,然后将b赋值给返回参数ppCallback。

第三步:这个时候,如果需要发送数据,则可以直接调用第二步保存的pChannel的Write方法。同时,如果收到数据,会触发第二步创建的实例b的OnDataReceived函数。

下面的代码例子来自于MSDN:

第一步:

function TTDVCClient.Initialize_(
pChannelMgr: IWTSVirtualChannelManager): HResult;
var
hr: HRESULT;
pListenerCallback: TWTSListenerCallback;
ptrListener: IWTSListener;
begin
FrmMain.ShowInfo('TTDVCClient.Initialize_');

pListenerCallback := TWTSListenerCallback.Create;
hr := pChannelMgr.CreateListener(
'DVC_Sample',
0,
pListenerCallback,
ptrListener);
Result := hr;
end;

其中,IWTSVirtualChannelManager接口声明如下:

IWTSVirtualChannelManager = interface(IUnknown)
['{A1230205-d6a7-11d8-b9fd-000bdbd1f198}']
function CreateListener(
{In}pszChannelName: PAnsiChar;//侦听器将侦听的端点名称。这是一个字符串值,其长度限制为MAX_PATH个字符。
{In}uFlags: ULONG;//该参数是保留的,必须设置为零。
{In}pListenerCallback: IWTSListenerCallback;//返回将接收传入连接通知的侦听器回调 ( IWTSListenerCallback )。
{Out}out ppListener: IWTSListener//IWTSListener对象的实例。
): HResult; stdcall;
end;

第二步:

function TWTSListenerCallback.OnNewChannelConnection(
const pChannel: IWTSVirtualChannel; data: BSTR; pbAccept: PBOOL;
out ppCallback: IWTSVirtualChannelCallback): HResult;
var
hr: HRESULT;
pCallback: TWTSVirtualChannelCallback;
begin
FrmMain.ShowInfo('TWTSListenerCallback.OnNewChannelConnection');

pbAccept^ := FALSE;
pCallback := TWTSVirtualChannelCallback.Create;
pCallback.SetChannel(pChannel);
ppCallback := pCallback;
pChannel._AddRef;
pbAccept^ := TRUE;
Result := S_OK;
end;

第三步:

function TWTSVirtualChannelCallback.OnDataReceived(cbSize: ULONG;
pBuffer: PBYTE): HResult;
begin
FrmMain.ShowInfo('TWTSVirtualChannelCallback.OnDataReceived');
Result := m_ptrChannel.Write(cbSize, pBuffer, nil);//将收到的数据返回给服务端
end;

其中,IWTSVirtualChannel接口声明如下:

IWTSVirtualChannel = interface(IUnknown)
['{A1230207-d6a7-11d8-b9fd-000bdbd1f198}']
function Write(
{In}cbSize: ULONG;//要写入的缓冲区的大小(以字节为单位)。
{In}pBuffer:PByte;//指向要写入数据的通道上的缓冲区的指针。你可以在调用返回后立即重用此缓冲区。
{In}pReserved: IUnknown//保留以供将来使用。该值必须为NULL。
): HResult; stdcall;
function Close(): HResult; stdcall;//关闭通道。
end;

方法Write:在通道上启动写入请求。所有写入都被视为异步。调用此方法复制pBuffer的内容并立即返回,因此可以回收缓冲区。由于内存复制,过多的Write()调用可能会导致客户端分配过多的内存。此通道上的Close()调用将取消任何挂起的写入。函数如果成功则返回S_OK 。
方法Close:如果通道尚未关闭,则Close()方法将调用IWTSVirtualChannelCallback::OnClose()方法进入关联的虚拟通道回调接口。通道关闭后,对其的任何Write()调用
都将失败。函数如果成功则返回S_OK 。

3、服务端插件程序编写

客户端插件DLL编译、安装完毕后(需要注意远程客户端的位数,如果是64位系统,必须编译为64位的DLL),我们来编写服务端插件。服务端插件可以是用户应用层EXE形式,也可以是服务程序等,在客户端连接后,即可进行操作。
这个例子来自于MSDN,是一个控制台形式的EXE,运行后,将打开连接客户端通道,同时创建一个读线程和一个写线程。

3.1 打开/连接客户端虚拟通道
打开客户端的虚拟通道使用函数WTSVirtualChannelOpenEx,函数原型为:

function WTSVirtualChannelOpenEx(
{IN}SessionId: DWORD;//远程桌面服务会话标识符。要指示当前会话,请指定 WTS_CURRENT_SESSION。你可以使用 WTSEnumerateSessions函数检索指定 RD 会话主机服务器上所有会话的标识符。
//要能够在其他用户的会话中打开虚拟频道,你必须具有虚拟频道权限。有关详细信息,请参阅 远程桌面服务权限。要修改会话权限,请使用远程桌面服务配置管理工具。
{_In_}pVirtualName: PAnsiChar;//在 SVC 的情况下,指向包含虚拟通道名称的以 null 结尾的字符串。SVC 名称的长度限制为CHANNEL_NAME_LEN个字符,不包括终止空值。
//在 DVC 的情况下,指向包含侦听器端点名称的以 null 结尾的字符串。DVC 名称的长度限制为MAX_PATH个字符。
{IN}flags: DWORD//要将通道作为 SVC 打开,请为此参数指定0。要将频道作为 DVC 打开,请指定 WTS_CHANNEL_OPTION_DYNAMIC。
): THANDLE; stdcall;

最后一个参数详解:打开 DVC 时,你可以通过指定WTS_CHANNEL_OPTION_DYNAMIC_PRI_XXX值之一以及WTS_CHANNEL_OPTION_DYNAMIC值来为正在传输的数据指定优先级设置。
WTS_CHANNEL_OPTION_DYNAMIC_NO_COMPRESS:禁用此 DVC 的压缩。你必须结合 WTS_CHANNEL_OPTION_DYNAMIC值指定此值。
WTS_CHANNEL_OPTION_DYNAMIC_PRI_LOW(默认):低优先级。数据将以低优先级在两侧发送。将此优先级用于所有大小的块传输,其中传输速度并不重要。在几乎所有 (95%) 的情况下,应该使用此标志打开通道。
WTS_CHANNEL_OPTION_DYNAMIC_PRI_MED:中等优先级。使用此优先级发送必须优先于低优先级通道中的数据的短控制消息。
WTS_CHANNEL_OPTION_DYNAMIC_PRI_HIGH:高优先级。对关键且直接影响用户体验的数据使用此优先级。传输大小可能会有所不同。显示数据属于此类。
WTS_CHANNEL_OPTION_DYNAMIC_PRI_REAL:实时优先级。仅在数据传输绝对关键的情况下使用此优先级。数据传输大小应限制为每条消息几百个字节。

此 API 支持静态虚拟通道 (SVC) 和动态虚拟通道 (DVC) 创建。如果 flags参数为零,则其行为与 WTSVirtualChannelOpen相同。可以通过指定适当的标志来打开 DVC。创建 DVC 后,可以使用与 SVC 相同的函数来读取、写入、查询或关闭。
如果函数成功,返回值是指定虚拟通道的句柄。如果函数失败,则返回值为NULL。要获取扩展的错误信息,请调用 GetLastError。

3.2 获取虚拟通道信息
函数原型为:

function WTSVirtualChannelQuery(
{IN}hChannelHandle: THANDLE;//处理由WTSVirtualChannelOpen函数打开的虚拟通道 。
{IN}unnamedParam2: WTS_VIRTUAL_CLASS;//
{OUT}ppBuffer: PPointer;//指向接收请求信息的缓冲区的指针。
{OUT}pBytesReturned: PDWORD//指向接收ppBuffer 参数中返回的字节数的变量的指针。
): BOOL; 

如果函数成功,则返回值为非零值。使用ppBuffer参数中返回的值调用 WTSFreeMemory函数以释放WTSVirtualChannelQuery分配的临时内存 。如果函数失败,则返回值为零。要获取扩展的错误信息,请调用 GetLastError。

在这个例子里面,显示了如何获得对可用于异步 I/O 的虚拟通道文件句柄的访问权限。首先,代码通过调用 WTSVirtualChannelOpen函数打开一个虚拟通道。然后代码调用 WTSVirtualChannelQuery函数,
指定 WTSVirtualFileHandle 虚拟类类型。 WTSVirtualChannelQuery返回一个文件句柄,你可以使用它来执行异步(重叠)读写操作。最后,代码通过调用 WTSFreeMemory 函数释放 WTSVirtualChannelQuery
分配的内存, 并通过调用 WTSVirtualChannelClose函数关闭虚拟通道 。
请注意,你不应显式关闭通过调用 WTSVirtualChannelQuery获得的文件句柄。这是因为 WTSVirtualChannelClose关闭了文件句柄。

其它详见代码:http://www.138soft.com/download/RDPVirtualChannels.zip。

分类:系统编程

发表评论

(required)

(required), (Hidden)

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>

TrackBack URL  |  RSS feed for comments on this post.


日历

2023年 2月
 12345
6789101112
13141516171819
20212223242526
2728  

近期文章