WebRTC
远程控制中WebRTC应用
- getUserMedia 获取多媒体数据
- RTCPeerConnection 建立P2P连接,传输多媒体数据
- RTCDataChannel 传输数据
原理
-
WebRTC 的通信过程需要两个客户端实时进行数据交换。交换内容分为两大部分:
-
交换 SDP(媒体信息)
- 定义:SDP (Session Description Protocol) 会话描述协议,是一个描述多媒体连接内容的协议
js//版本 v=0 //<username> <sess-id> <sess-version> <nettype> <addrtype> <unicast-address> o=- 3089712662142082488 2 IN IP4 127.0.0.1 //会话名 s=- //会话的起始时间和结束时间,0代表没有限制 t=0 0 //表示音频传输和data channel传输共用一个传输通道传输的媒体,通过id进行区分不同的流 a=group:BUNDLE audio data //WebRTC Media Stream a=msid-semantic: WMS //m=audio说明本会话包含音频,9代表音频使用端口9来传输,但是在webrtc中现在一般不使用,如果设置为0,代表不传输音频 //使用UDP来传输RTP包,并使用TLS加密, SAVPF代表使用srtcp的反馈机制来控制通信过程 //111 103 104 9 0 8 106 105 13 110 112 113 126表示支持的编码,和后面的a=rtpmap对应 m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126
-
交换 ICE(网络信息)
- 定义: Interactive Connectivity Establishment,互动式连接建立,是一种框架,使各种NAT穿透技术(STUN,TURN...)可以实现统一。该技术可以让客户端成功地穿透远程用户与网络之间可能存在的各类防火墙。
- 原理:是因为有 NAT技术保护内网地址的安全性,导致 peerA 不能直接连到 peerB
- NAT技术会保护内网地址的安全性,所以这就会引发个问题,就是当我采用P2P之中连接方式的时候,NAT会阻止外网地址的访问,这时我们就得采用NAT穿透了。
- 于是我们就有了如下的思路:我们借助一个公网IP服务器,Peer-A与Peer-B都往公网IP/PORT发包,公网服务器就可以获知Peer-A与Peer-B的IP/PORT,又由于Peer-A与Peer-B主动给公网IP服务器发包,所以公网服务器可以穿透NAT-A与NAT-B并发送包给Peer-A与Peer-B。
- 所以只要公网IP将Peer-B的IP/PORT发给Peer-A,Peer-A的IP/PORT发给Peer-B。这样下次Peer-A与Peer-B互相消息,就不会被NAT阻拦了。
- WebRTC的NAT/防火墙穿越技术,就是基于上述的一个思路来实现的。在WebRTC中采用ICE框架来保证RTCPeerConnection能实现NAT穿越。
-
RTCPeerConnection 建立P2P连接
交换 SDP(媒体信息)
- 控制端
- 创建RTCPeerConnection
- 发起连接 createOffer, 得到 offer SDP
- setLocalDescription , 设置 offer SDP
- 将控制端的 offer SDP "发送"给傀儡端
- 傀儡端
- 创建RTCPeerConnection
- 添加桌面流 addstream
- setRemoteDescription , 设置控制端的 offer SDP
- 响应连接,createAnswer , 得到 answer SDP
- setLocalDescription , 设置 answer SDP
- 将傀儡端的 answer SDP "发送"给控制端
- 控制端 setRemoteDescription , 设置控制端的 answer SDP
- 控制端
js
// 1. 创建RTCPeerConnection
const pc = new window.RTCPeerConnection({})
// 2. 发起连接 createOffer, 得到 offer SDP
async function createOffer() {
let offer = await pc.createOffer({
offerToReceiveAudio: false,
offerToReceiveVideo: true
})
await pc.setLocalDescription(offer) // 3. setLocalDescription , 设置 offer SDP
return pc.localDescription
}
createOffer().then((offer) => {
ipcRenderer.send('forward', 'offer', {type: offer.type, sdp: offer.sdp}) // 将控制端的 offer SDP 通过信令服务器 给傀儡端,,也是通信的开始点
})
// 监听流媒体,监听到了播放▶️
pc.onaddstream = (e) => {
console.log('addstream', e)
peer.emit('add-stream', e.stream)
}
// app.js 控制端主入口
const peer = require('./peer-control')
peer.on('add-stream', (stream) => {
console.log('play stream')
play(stream)
})
let video = document.getElementById('screen-video')
function play(stream) {
video.srcObject = stream
video.onloadedmetadata = function() {
video.play()
}
}
发送的步骤是 ipcRenderer.send => ipcMain.on => signal.send => ws.send => 到 server 端,通过在被控制后建立的关联关系,ws.sendRemote 到远端 => signal.emit('offer', data.data) => signal.on('offer') => 通知傀儡端收到 offer sendMainWindow('offer', data)
- 傀儡端
- 当收到 控制端下发的 offer 事件后,执行
js
ipcRenderer.on('offer',(offer) => {
const pc = new window.RTCPeerConnection(); // 1. 创建RTCPeerConnection
async function createAnswer(offer) {
let stream = await getScreenStream()
pc.addStream(stream)
await pc.setRemoteDescription(offer); // 3. setRemoteDescription , 设置控制端的 offer SDP
await pc.setLocalDescription(await pc.createAnswer()); // 5. setLocalDescription , 设置 answer SDP
// send answer
return pc.localDescription
}
async function createAnswer(offer) { // 4. 响应连接,createAnswer , 得到 answer SDP
let stream = await getScreenStream()
pc.addStream(stream) // 2. 添加桌面流 addstream
await pc.setRemoteDescription(offer);
await pc.setLocalDescription(await pc.createAnswer());
// send answer
return pc.localDescription
}
createAnswer(offer).then((answer) => {
ipcRenderer.send('forward', 'answer', {type: answer.type, sdp: answer.sdp}) // 4. 将傀儡端的 answer SDP "发送"给控制端
})
async function getScreenStream() {
const sources = await desktopCapturer.getSources({types: ['window', 'screen']})
return new Promise((resolve, reject) => {
navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sources[0].id,
maxWidth: window.screen.width,
maxHeight: window.screen.height
}
}
}).then(stream => resolve(stream))
.catch(err => {
console.log("-> err", err);
reject(err)
})
})
}
})
- 控制端
js
// 5. 控制端 setRemoteDescription , 设置控制端的 answer SDP
signal.on('answer', (data) => {
sendControlWindow('answer', data)
})
ipcRenderer.on('answer', (e, answer) => {
setRemote(answer).catch(e => {
console.log("-> 设置错误e", e);
})
})
async function setRemote(answer) {
await pc.setRemoteDescription(answer)
console.log('create-answer', pc)
}
交换 ICE(网络信息)
NAT打洞
-
NAT (network address translation)网络地址转换
- clint A 和 clint B 都有内网 ip,透过 NAT 可以映射在一个外网 ip
- clint B 先和服务器建立链接,知道了 B 的内外网的映射,再传给A,A就可以根据这个打好的洞,建立和B的链接
-
webRTC 中有一个集成好的机制,ICE
- STUM 服务
-
pc.onicecandidate 在建立 peerConnection 连接后会自动发起
- 控制端
- 控制端创建 pc connection 链接后,触发 pc.onicecandidate ,把自己的 candidate 传给对方
- 监听到 candidate,添加对方发送过来的 candidate
js
pc.onicecandidate = (e) => {
if (e.candidate) {
console.log('candidate', JSON.stringify(e.candidate))
ipcRenderer.send('forward', 'control-candidate', JSON.stringify(e.candidate))
// 告知其他人
}
}
- 傀儡端
- 监听到 candidate,添加对方发送过来的 candidate
- 自己创建的 pc 时,也有一个 傀儡端的 candidate,发送过去
js
ipcRenderer.on('candidate', (e, candidate) => {
addIceCandidate(JSON.parse(candidate)) // 1. 监听到 candidate,添加对方发送过来的 candidate
})
async function addIceCandidate(candidate) {
if (!candidate) return
await pc.addIceCandidate(new RTCIceCandidate(candidate))
}
pc.onicecandidate = (e) => { // 2. 自己创建的 pc 时,也有一个 傀儡端的 candidate,发送过去
// 告知其他人
if (e.candidate) {
ipcRenderer.send('forward', 'puppet-candidate', JSON.stringify(e.candidate))
console.log("-> e.candidate", JSON.stringify(e.candidate));
}
}
- 控制端
js
ipcRenderer.on('candidate', (e, candidate) => { // 2. 监听到 candidate,添加对方发送过来的 candidate
addIceCandidate(JSON.parse(candidate))
})
let candidates = []
async function addIceCandidate(candidate) {
if (!candidate) return
candidates.push(candidate)
if (pc.remoteDescription && pc.remoteDescription.type) {
for (let i = 0; i < candidates.length; i++) {
await pc.addIceCandidate(new RTCIceCandidate(candidates[i]))
}
candidates = [] // 清空 candidates
}
}
如何捕获媒体流?
- navigator.mediaDevices.getUserMedia ( MediaStreamConstraints)
- 返回: Promise,成功后 resolve 回调一个 MediaStream 实例对象
- 参数: MediaStreamConstraints
- audio: Boolean MediaTrackConstraints
- video: Boolean MediaTrackConstraints
- width: 分辨率
- height: 分辨率
- frameRate: 帧率,比如{ ideal: 10,max: 15 }
如何播放媒体流对象
- 浏览器
js
navigator.mediaDevices.getUserMedia({
auto:true,
video:{
frameRate:{max:30}
}
}).then(stream =>{
console.log(stream)
var video = document.getElementById('video')
video.srcObject = stream
video.onloadedmetadata = function(e) {
video.play();
}
})
- Electron 桌面捕获视频
- 桌面捕获 数据源
js
// renderer.js
const EventEmitter = require('events')
const peer = new EventEmitter()
const {ipcRenderer} = require('electron')
const remote = window.require('@electron/remote')
const {desktopCapturer} = remote
async function getScreenStream() {
const sources = await desktopCapturer.getSources({types: ['window', 'screen']})
console.log("-> sources", sources);
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: {
chromeMediaSource: 'desktop',
chromeMediaSourceId: sources[0].id,
maxWidth: window.screen.width,
maxHeight: window.screen.height
}
}
})
console.log("-> stream", stream);
peer.emit('add-stream', stream)
} catch (e) {
console.log("-> e", e);
}
}
getScreenStream()
module.exports = peer
- 播放视频
js
const peer = require('./peer-control')
peer.on('add-stream', (stream) => {
console.log('play stream')
play(stream)
})
let video = document.getElementById('screen-video')
function play(stream) {
video.srcObject = stream
video.onloadedmetadata = function() {
video.play()
}
}
基于WebRTC的RTCDataChannel 传输数据
- 对于已经建立 P2P 链接的两端,可以直接使用通道传递数据,不必经过服务端
- 基于 SCTP (传输层,有着 TCP,UDP的优点)
- 控制端
- 控制端监听 onkeydown , onmouseup 事件,发送 robot 信息
- 控制端监听到 robot 后,发送给远程的傀儡端
js
const pc = new window.RTCPeerConnection({})
let dc = pc.createDataChannel('robotchannel', {reliable: false}); // 建立链接, {reliable: false} 不必一定触达(连上)
dc.onopen = function() {
console.log('opened')
peer.on('robot', (type, data) => {
dc.send(JSON.stringify({type, data}))
})
}
dc.onmessage = function(event) {
console.log('message', event)
}
dc.onerror = (e) => {console.log(e)}
- 傀儡端
- 发现新的数据通道传输
- e.channel.onmessage 监听数据通道消息。接收到 data,ipcRenderer 发送 robot 信息
- 主进程接收到 robot 信息,做出相应的动作
js
const pc = new window.RTCPeerConnection();
pc.ondatachannel = (e) => { // 1. 接收到 data,ipcRenderer 发送 robot 信息
console.log('data', e)
e.channel.onmessage = (e) => { // 2. e.channel.onmessage 监听数据通道消息。接收到 data,ipcRenderer 发送 robot 信息
console.log('onmessage', e, JSON.parse(e.data))
let {type, data} = JSON.parse(e.data)
console.log('robot', type, data)
if (type === 'mouse') {
data.screen = {
width: window.screen.width,
height: window.screen.height
}
}
ipcRenderer.send('robot', type, data)
}
}
// 3. 主进程接收到 robot 信息,做出相应的动作
ipcMain.on('robot', (e, type, data) => {
console.log('handle', type, data)
if(type === 'mouse') {
handleMouse(data)
} else if(type === 'key') {
handleKey(data)
}
})
robot.js 控制鼠标和键盘
- 是基于c++写的原生模块,要根据不同的 node 版本做编译
- 自动编译
- electron-rebuild
js
// 安装
npm i electron-rebuild -D
// 编译
npx electron-rebuild
vkey 键值转化
js
npm i vkey
- 根据 window 的 keyboard 事件。e.keyCode 码来转化成对应的按键库