by fanxiushu 2018-08-21 转载或引用请注明原始作者。
到目前为止,包括本文发布了六个系列,能坚持到现在也属不易。
第一篇:
https://blog.****.net/fanxiushu/article/details/73269286 windows远程桌面实现之一 (抓屏技术总览 MirrorDriver,DXGI,GDI)
第二篇:
https://blog.****.net/fanxiushu/article/details/76039801 Windows远程桌面实现之二(抓屏技术之MirrorDriver镜像驱动开发)
第三篇:
https://blog.****.net/fanxiushu/article/details/77013158 Windows远程桌面实现之三(电脑内部声音采集,录音采集,摄像头视频采集)
第四篇:
https://blog.****.net/fanxiushu/article/details/78869719 Windows远程桌面实现之四(在现代浏览器中通过普通页面访问远程桌面)
第五篇:
https://blog.****.net/fanxiushu/article/details/80996391 Windows远程桌面实现之五(FFMPEG实现桌面屏幕RTSP,RTMP推流及本地保存)
前三篇都是介绍在windows平台采集各种需要的数据,包括各种技术要点。
从驱动到应用层采集,几乎能想到的采集办法都曾尝试过。
第四篇介绍的是如何在现代浏览器中利用最新的HTML5和WebSocket技术在网页上展现和控制远程桌面。
第五篇则是附加功能,实现桌面图像和声音录制到本地视频文件或者推流到RTSP,RTMP服务器等。
这一篇还会介绍一个附加功能:在网页中开启录音,然后打开网页端的远程控制,可以互相说话,类似电话会议一样的功能。
这些文中,并没介绍图像和音频的各种编码和解码算法,
一是因为对这些算法原理不太熟悉,二是这些算法都有成熟的开源库。
这里罗列一下主要使用到的开源库:
ffmpeg, x264,openh264, fdk-aac, libjpeg-turbo, libyuv, openssl, x265 ,libvpx, zlib, liblzma 等等。
(至于更加详细的介绍,可以运行xdisp_virt之后用浏览器打开,查看里边的about页面内容。)
可以看到编码和解码库,用得非常之多。所有的开源库都是静态编译进程序,
因此,你可以把 xdisp_virt.exe单独复制到任何一台windows机器直接运行,而不需要各种依赖库。
这些文章也没提到底层网络通讯部分,这篇文章会有所提及,主要是根据 xdisp_virt软件的内部网络通讯框架来介绍。
总之,实现一个远程桌面,牵涉到的技术挺多,也挺杂。
从windows驱动,各种数据采集,图像音频编解码,网络通讯,
客户端展现和控制,如果是网页客户端还包括javascript以及HTML5等前端技术。
xdisp_virt项目的来源在第一篇(抓屏技术总览 MirrorDriver,DXGI,GDI)和第二篇(抓屏技术之MirrorDriver镜像驱动开发)说得比较明白,
是因为当时开发mirror驱动,需要一个测试程序测试mirror的效果,因此简单开发了一个远程桌面测试程序,
一开始使用jpeg压缩,感觉效果不太理想,后来尝试着使用H264压缩,效果非常好,
同时为了圆大学时候的梦,于是决定继续把这个远程桌面做下去,结果就是一发不可收拾,持续到现在。
后来为了解决在公网也能远程控制内网机器的问题,开发了xdisp_server.exe程序,
让内网的xdisp_virt都链接到运行在公网的xdisp_server程序,然后所有控制客户端连接xdisp_server,这样就能远程控制内网的机器了。
也就是xdisp_server起了一个中转服务器的的作用。
开始前,先提供最新版本程序的下载地址:
****:
https://download.****.net/download/fanxiushu/10617305
GITHUB:
https://github.com/fanxiushu/xdisp_virt
基本上包括两个程序。xdisp_virt.exe和xdisp_server.exe(以及两个对应的配置文件),还包含一个mirror驱动,
这个驱动是为了在WINXP,WIN7平台更高效的采集桌面数据而开发的,当然,这个mirror驱动也可以安装到WIN8,WIN10系统中。
均是使用C/C++语言开发。网页客户端包括html网页文件和js脚本文件,都被打包进了程序。
xdisp_server.ini和xdisp_virt.ini配置文件都设置成了默认值,不过有些字段需要你重新设置,
比如 display_name是显示名,
ssl_crt_file和ssl_key_file是SSL证书相关路径,ssl_socket_only如果设置 1,则整个通信只允许SSL加密通讯。
程序只开启一个侦听端口,来接收处理所有的请求,包括加密SSL。Web请求,原生客户端私有协议请求等。
至于程序如何在一个端口中区别这些网络请求。下面会有说明。
web_auth_string字段配置的是网页客户端登录的用户名和密码,
此验证方式很弱,因此强烈要求在SSL环境(也就是HTTPS请求)中使用。
server_ip,server_port,server_auth_string,server_ssl_login字段是xdisp_virt程序登录到xdisp_server服务端相关的配置。
如果server_ssl_login设置为1,表示从xdisp_virt到xdisp_server的整个数据传输通讯都是SSL加密的。
至于 *_allow 字段,则是更加严格的限制只允许哪些客户端的IP地址连接登录,也就是比SSL加密还更安全。
假设运行xdisp_virt.exe程序的机器的IP地址是192.168.100.1, 是内网地址。
假设运行xdisp_server.exe程序的机器的IP地址是 121.1.1.120, 是个公网地址。
把xdisp_virt.ini配置文件的server_ip等字段配置到 121.1.1.120地址。
两个程序以服务方式运行之后(注意取消防火墙的限制)。
你就可以在相同局域网内,在手机等移动设备或者另外的PC电脑打开浏览器,输入 https://192.168.100.1:11000 (假设配置的是11000端口)
或者在任何联网的地方,用手机浏览器或者其他PC电脑浏览器,打开 https://121.1.1.120:32000 (假设xdisp_server端口是32000)
然后就可以非常方便的控制 运行xdisp_virt程序的机器了,以及设置编码参数,设置推流等等。
下图是一些运行效果:
这个是在macOS系统中自带的Safari浏览器,正在远程看电影,远程控制的是 1920X1080的windows 7 的电脑:
这个是在iPhone6手机中的自带浏览器的效果,我的手机比较古老,如果是最新的iPhone手机,对1080p的支持应该会更好。
下图是图像和音频各种编码配置和其他相关配置:
配置的参数比较多,不过应该都能看明白,其中图像编码以H264为主,配置的参数也多。
你如果熟悉x264,可以设置出更适合你的图像效果。
正如第四篇文章(在现代浏览器中通过普通页面访问远程桌面)描述的,在浏览器中是使用javascript解码的,
虽然现在主流浏览器都支持 asm或wasm(
WebAssembly,其实就是把脚本预先编译成中间字节码,类似于java虚拟机制一样的玩意。)
这对这种编码解码的CPU计算密集型应用,wasm是有好处的,虽然号称是接近原生程序的效率。
但实际上测试下来也就能接近50%的效率。
因为我在自己的电脑上,win10, cpu i5 7267u,集显intel 650,使用C/C++开发的原生客户端程序,
远程桌面播放1920X1080全屏视频,CPU占用大概在 10%-15%左右波动,
而且使用的还只是 ffmpeg软解码,图像还只是GDI渲染而非DirectX渲染。
而同样环境下,使用浏览器,CPU占用则在30%-40%, 有时还能看到CPU和GPU的占用一下子上升上去。
可见,在远程桌面的图像激烈活动,比如视频或者打游戏的时候,
使用浏览器作为客户端方式的远程桌面,CPU等资源消耗是挺厉害的,与原生客户端还是没法比。
因此你需要一个配置比较好的电脑,使用浏览器方式控制远程桌面,才能感觉比较爽。
下图是原生客户端的效果图,不过是windows平台的,暂时没精力去做其他平台的客户端。
上图显示的是特殊效果的图像,黑白效果的二值图像,是不是感觉又回到了少年时代的黑白电视的境界?
相比于上次发布的版本,这次更新其实发生了很大变化。
以前是web一个侦听端口,原生客户端一个端口,网络通讯安全程度也比较低。
这次是全部都集成到一个侦听端口,而且增加了SSL安全连接,SSL安全连接也同样集成到同一个端口中。
这么多类型的请求,如何区分呢?
其实也不难,先看WEB请求,当客户端连接成功了,WEB请求不是发送GET,就是发送POST等HTTP命令,
因此第一个字节不是 'G'就是 'P',
而我定义的私有协议,客户端连接成功之后,首先发送的就是登录数据包,第一个字节固定为CMD_LOGIN(定义为1),
再看SSL安全连接,客户端连接成功后,发送的第一个字节固定为 0x16(SSL握手协议类型)。
因此当客户端连接上来,先收取一个字节的数据,根据上面的依据判断,不同的值,分别路由到不同的处理流程。就这么简单,
虽然原理简单,处理起来还是比较复杂。
整个底层通讯框架使用的是windows平台的完成端口模型,
这里使用完成端口不是为了处理连接数多的问题,而是为了处理收发的数据包多的问题。
通讯框架做成异步回调函数方式,简单的说,就是提供一组回调函数,当有客户端连接上来,或者接收到某个客户端的数据,
对应的回调函数都会被调用, 同时提供一个异步发送函数,发送的数据投递给底层框架,直到数据传输完成,调用完成通知回调函数。
如果熟悉高级语言,比如javascript的WebSocket编程方式,就能很清楚的明白这种异步框架方式。
只是这些高级语言中都是集成这种异步框架,程序员只管调用就行,而在C/C++中要实现这种方式,却不是个简单的事情。
这里并不使用第三方框架,而是自己实现,这篇文章也并不打算介绍如何自己实现。
以这种异步通讯框架处理通讯传输,相对图像音频的发送就容易些。
当客户端成功登录上来,加入到一个队列中,在一个线程中定时采集windows桌面图像数据,另一个线程采集音频数据,
采集好之后,做图像或音频编码,编码完成之后,组成一定格式的数据包,这个数据包就是发送给客户端的图像和音频数据。
然后遍历客户端的队列,对每个客户端,调用通讯框架的异步发送函数,就这样数据包就被发送给每个客户端了。
这就是xdisp_virt和xdisp_server底层通讯的基本框架,
当然具体处理的数据包远不止图像音频数据包,还包括其他许多类型的数据包,处理的逻辑也比较多和杂。
xdisp_server还牵涉到一个采集端和多个客户端如何关联等一系列问题。
当初把web和原生侦听端口合并到一起,是有一次发神经,把原生客户端连接到web端口,死活都连不上,还以为程序出问题了。
折腾了半天,原来是端口搞错了,因此决定合并到一起。
再到后来,发现在页面上可以录音,
然后把录音传给被控制端,被控制端播放,然后播放的声音又被采集到,再回传回来或者回传给其他客户端。
就这样,组成一个电话会议室。
因此就想在web中加入录音功能,这需要调用webrtc标准中的GetUserMedia函数,然后研究发现有些浏览器,
比如chrome,Safari等必须是在https安全环境下,才能正常调用GetUserMedia获得话筒。
于是,决定重新打造整个程序通讯,让它 支持SSL安全连接,其实这个很早就打算实现的,因为在WEB环境中,
全是明文传输,虽然音视频数据通讯的是WebSocket中定义的私有协议,但是还是很容易被第三方**和侦听到。
只是一直没有说服自己的修改支持SSL安全连接的动力。
SSL是在TCP之上的一个加密层,我并不想做颠覆性的修改原来程序的整个通讯框架,
而只是想在原来的异步通讯框架函数层上同样也增加一层SSL加密和解密框架函数层,
这样原来上层调用的异步框架函数,全部改成调用SSL的框架函数,而SSL框架则调用下层的异步通讯函数。
要这样做,首先要考虑的就是需要把SSL加密和解码过程,跟socket网络通讯分隔开来,不能凑在一起。
这里使用openssl开源库,仔细研究openssl开源库,还真可以把socket和SSL加密解密分隔开来。
openssl的加密解密都是基于BIO操作的,BIO是openssl定义的一个包容器对象,
BIO可以定向到socket类型,也可以定向到内存类型,或者其他类型。
显然我们加密解密都是把BIO定向到内存中。
初始化的伪代码看起来如下:
SSL* ssl=SSL_new(ctx);
BIO* read_bio = BIO_new(BIO_s_mem()); /////创建内存类型的BIO ,读
BIO* write_bio= BIO_new(BIO_s_mem());/////写
....
SSL_set_bio(ssl, read_bio, write_bio); ///读和写的BIO关联到SSL中。
.....
然后我们如果从网络通讯底层接收到对方发来的已经加密的SSL数据 enc_data,长度为 enc_len 。
我们需要把enc_data写入openssl的read_bio中去解密,这时候调用BIO_write函数,如下
BIO_write(read_bio, enc_data,enc_len);
调用BIO_write函数之后,再调用SSL_read函数读取内存BIO解密的数据,如下:
SSL_read(ssl, dec_buf, dec_len); // dec_buf接收解密之后的数据,这个就是我们需要的数据。
反过来,当我们有数据需要发送,调用SSL_write把数据写到write_bio中去加密,如下:
SSL_write(ssl, buffer, length);// buffer就是要写的数据
数据被写到openssl的BIO内存之后,需要检测write_bio的加密的数据是否完成需要读取出来。
这时候调用 BIO_ctrl_pending 函数检测是否有数据,
如果返回大于0,表示有数据,需要调用BIO_read读取已经加密了的数据,如下:
enc_len= BIO_read(write_bio, enc_buffer, length);///返回值就是读取到的数据长度,
然后我们再把读取到的已经加密的enc_buffer数据通过底层网络发送出去。
同时注意,在建立SSL连接阶段,调用 SSL_do_handshake 函数进行交互。
SSL_do_handshake和SSL_read调用之后,都极有可能产生数据需要发送给对端进行交互的数据,因此,每次调用完这两个函数,
必须再次调用 BIO_ctrl_pending 检测是否需要数据发送,是的话,必须及时发送出去,才能完成接下来的SSL数据交换。
代码片段如下,代码摘自xdisp_virt代码工程:
int __ssl_layer_t::read(char* buf, int length)
{
int ret = 0;
bool retry = false;
L:
if (length > 0) {
ERR_clear_error();
ret = BIO_write(this->r_bio, buf, length); //写到ssl内存进行加密处理
if (ret == length) { // success
}
else {
///
if (BIO_should_retry(this->r_bio)) {
ret = 0;///
retry = true;
printf("BIO_write: BIO_should_retry \n");
}
else { // error
printf("BIO_write -> input bio error.\n");
////
return -1;
}
//////
}
//////////
}
while (true) {
////
if ( !this->is_connected ) { //如果在握手阶段,
/////
ERR_clear_error();
ret = SSL_do_handshake(ssl);
if (ret == 1) { // success
////
this->is_connected = true;
/// callback
ret = this->ptr_new_client(&layer, this->param); ///
if (ret < 0) {
return -1;
}
//////
goto L2; ///开始读数据
}
else {
////
if (is_fatal_error(ssl, ret)) {
///
printf("SSL_do_handshake ret=%d, err=%d\n", ret, SSL_get_error(ssl,ret) );
return -1;
}
//////
}
//////
break;
}
////
L2:
ERR_clear_error();
ret = SSL_read(this->ssl, this->recv_buffer + this->recv_pos, this->recv_length - this->recv_pos); //从ssl内存解码
// printf("");
if (ret <= 0) { // <=0 出错或者数据不够
if (is_fatal_error(ssl, ret)) {
///
if (SSL_get_error(ssl, ret) == SSL_ERROR_SYSCALL) {//非常诡异的问题,!!!
printf("***-- SSL_read: warning SSL_ERROR_SYSCALL, continue process---- \n");
break;
}
////
printf("SSL_read: error. ret=%d, err=%d\n", ret, SSL_get_error(ssl, ret) );
return -1;
}
break;
////
}
///////
this->recv_pos += ret; ////
/////
if (this->is_any_length) { //接收到任何长度都调用回调函数
this->recv_buffer[this->recv_pos] = 0; ///
int curr = this->recv_pos;
///callback
int r = this->ptr_read(&layer, recv_buffer, this->recv_pos, this->param);
if (r < 0)return -1;
///
if (this->recv_pos >= this->recv_length) this->recv_pos = 0; ///重新开始
if (this->is_keep_recv_pos) {
this->is_keep_recv_pos = false;
if (curr < this->recv_length)this->recv_pos = curr;
}
////
}
else {
//接收到指定长度
if (this->recv_pos == this->recv_length) {
int curr = this->recv_pos;
///callback
int r = this->ptr_read(&layer, recv_buffer, this->recv_pos, this->param);
/////
this->recv_pos = 0; ///
if (this->is_keep_recv_pos) { ///
this->is_keep_recv_pos = false;
if (curr < this->recv_length)this->recv_pos = curr;
}
/////
if (r < 0)return -1;
////////
}
///
}
/////
}
////
w_lock();
ret = _check_write();
w_unlock();
if (ret < 0) {
return -1;
}
if (retry) {
retry = false;
goto L;
}
//////
return length;
}
int __ssl_layer_t::_check_write()
{
int pending = BIO_ctrl_pending(this->w_bio);//BIO内存是否有数据发送
if (pending > 0) {
/// write
if (send_size < pending || !send_buffer) {
if (send_buffer)free(send_buffer);
send_size = pending + 512;
send_buffer = (char*)malloc(send_size);
}
///
ERR_clear_error();
int ret = BIO_read(this->w_bio, this->send_buffer, this->send_size);
if (ret <= 0) {
if (is_fatal_error(ssl, ret)) {
printf("*** BIO_read ret=%d, err=%d\n", ret, SSL_get_error(ssl, ret));
return -1;
}
}
else {
/// callback
ret = this->ptr_write(&layer, this->send_buffer, ret, this->param);
if (ret < 0) {
printf("*** SSL ptr_write to socket err\n");
return -1;
}
/////
}
}
return 0;
}
以上代码中ptr_new_client,ptr_read,ptr_write都是回调函数,
ptr_new_client表示SSL握手完成,有SSL客户端连接上来了。
ptr_read,是读取到SSL并且解码了的数据,
ptr_write是加密了的数据,需要通过网络发送出去。
至此,网络通讯部分简单介绍到此,主要介绍一些重点内容,其他方面的这里也就不一一列举了。
在上面的一些效果图中,会看到一个红色按钮 “开启话筒”, 这个功能就是上面简单提到过的,
在浏览器中录音,录音数据经过编码发给被控制端,被控制接收并且播放,这样被控制端和控制端就可以互相通话。
然后被控制端采集电脑内部的声音,也把这个录音也采集到了,结果发给所有连接上来的客户端,这样其实就是组成了一个简单电话会议。
新版本xdisp_virt的程序,不单可以采集电脑内部声音,还可以采集电脑话筒一共4路音频数据,
然后这些音频数据经过混音处理,发给控制端播放,当做完这个功能时候。
就想着反过来,把控制端的声音也录制下来,发给被控制端。于是就有了在浏览器“开启话筒”的功能。
虽然好像没啥大用处,也就是好玩。这个功能,也就是玩玩的效果,如果使用中有什么好的建议和意见,不妨提出来。
这里说说在浏览器中如何采集录音数据,以及通过javascript压缩成mp3,然后再传输给被控制端。
浏览器中采集录音和摄像头的数据,需要调用GetUserMedia 接口,这个是属于WebRTC标准中的一部分,应该属于数据采集那部分吧。
再翻看各种浏览器为了实现这些功能的历史,真是各个浏览器混战的年代,直到最近才标准化为WebRTC。
所以各个浏览器接口稍有不同,如下方式调用:
window.AudioContext = window.AudioContext || window.webkitAudioContext;
navigator.getUserMedia = navigator.getUserMedia || navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia || navigator.msGetUserMedia;
window.URL = window.URL || window.webkitURL;
navigator.getUserMedia({ audio: true }, // 这里只采集话筒
function (stream) {
///
var audio_context = new window.AudioContext;
record_source = audio_context.createMediaStreamSource(stream);
record_node = (audio_context.createScriptProcessor ||
audio_context.createJavaScriptNode).call(audio_context, 1024*4, 2, 2);// 设置缓存长度,双声道
record_node.onaudioprocess = function (e) {
var left = e.inputBuffer.getChannelData(0); // left
var right = e.inputBuffer.getChannelData(1);// right
////left和right就是采集到的录音数据,以是float类型,范围从 -1到1,
///// 接着我们就可以把这数据通过javascript编码成需要的类型,这里编码成mp3,使用的是GITHUB上的开源库:
https://github.com/zhuker/lamejs
}
.......
/////连接起来,开启录音
record_source.connect(record_node);
record_node.connect(audio_context.destination);
},
function (e) {
alert('err: '+e);
});
看起来够简单吧,有兴趣可以下载我在****和GITHUB提供的HTML和JS相关代码,
虽然xdisp_virt和xidsp_server公布的只是程序,但是网页客户端的全部html和js文件都提供了。
js解码全是从GITHUB找的开源库,主要包括h264bsd和jsmpeg两个,以及其他一些音频js解码库。
如果没有GITHUB共享的这些图像音频的 js 解码代码,是无法实现网页客户端的。