Windows遠程桌面實現之六(新版本框架更新,以及網頁HTML5音頻採集通訊)

                                                                                                   by fanxiushu 2018-08-21 轉載或引用請註明原始作者。

到目前爲止,包括本文發佈了六個系列,能堅持到現在也屬不易。
第一篇:
https://blog.csdn.net/fanxiushu/article/details/73269286  windows遠程桌面實現之一 (抓屏技術總覽 MirrorDriver,DXGI,GDI)
第二篇:
 https://blog.csdn.net/fanxiushu/article/details/76039801 Windows遠程桌面實現之二(抓屏技術之MirrorDriver鏡像驅動開發)
第三篇:
 https://blog.csdn.net/fanxiushu/article/details/77013158 Windows遠程桌面實現之三(電腦內部聲音採集,錄音採集,攝像頭視頻採集)
第四篇:
https://blog.csdn.net/fanxiushu/article/details/78869719 Windows遠程桌面實現之四(在現代瀏覽器中通過普通頁面訪問遠程桌面)
第五篇:
 https://blog.csdn.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起了一箇中轉服務器的的作用。

開始前,先提供最新版本程序的下載地址:
CSDN:
https://download.csdn.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);
         });

看起來夠簡單吧,有興趣可以下載我在CSDN和GITHUB提供的HTML和JS相關代碼,
雖然xdisp_virt和xidsp_server公佈的只是程序,但是網頁客戶端的全部html和js文件都提供了。
js解碼全是從GITHUB找的開源庫,主要包括h264bsd和jsmpeg兩個,以及其他一些音頻js解碼庫。
如果沒有GITHUB共享的這些圖像音頻的 js 解碼代碼,是無法實現網頁客戶端的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章