前言
- 上一次进行了手动交换sdp成功进行了ice连接,但是正常情况下,不可能是让你手动交换,因为你能手动交换,说明你们之间已经有了传输通道,不然怎么获取对方的sdp。所以一般情况下,需要有个中间的服务器用来交换sdp,两个客户都通过中间服务器交换了sdp后实现ice连接。貌似这个过程的专业名词叫信令转发。
服务器搭建
- 首先初始化个npm项目,安装ws
- 起个ws到8001
const webSocket = require("ws");
const wss = new webSocket.Server({ port: 8001 });
const code2ws = new Map();
wss.on("connection", function connection(ws, request) {
let code = Math.floor(Math.random() * (999999 - 100000)) + 100000;
code2ws.set(code, ws);
ws.sendData = (event, data) => {
ws.send(JSON.stringify({ event, data }));
};
ws.sendError = (msg) => {
ws.sendData("error", { msg });
};
ws.on("message", function incoming(message) {
console.log("incoming", message);
let parsedMessage = {};
try {
parsedMessage = JSON.parse(message);
} catch (e) {
ws.sendError("message invalid");
console.log("parse message error", e);
return;
}
let { event, data } = parsedMessage;
if (event === "login") {
ws.sendData("logined", { code });
} else if (event === "control") {
let remote = +data.remote;
if (code2ws.has(remote)) {
ws.sendData("controlled", { remote });
ws.sendRemote = code2ws.get(remote).sendData;
code2ws.get(remote).sendRemote = ws.sendData;
ws.sendRemote("be-controlled", { remote: code });
}
} else if (event === "forward") {
if (ws.sendRemote) {
ws.sendRemote(data.event, data.data);
}
}
});
ws.on("close", () => {
code2ws.delete(code);
});
});
- http://websocket.org/echo.html 这个网址可以快速测试ws有效性,直接填自己地址进行连接,连上说明ok。
- 下面的message可以模拟客户端发送请求:
{"event":"login"}
- 发送login,则触发login
- 打开另一个网页,模拟另一个客户端,同样连接ws,发送:
{"event":"control","data":{"remote":"664497"}}
- remote内容为login收到的code,其实就是每多一个链接在map上存一个。
- forward则是直发,发送“data”:{“data”:“xxx”}即可收到xxx的内容。
electron
- 我们可以用electron来模拟制作个远程控制交换sdp。
- 每个拿到electron客户端的会去连接websocket服务交换sdp,利用electron自带的node服务获取桌面流,然后进行通信。
- 原理和上面差不多,在启动时监听主窗口以及ws服务器传来的消息:
const { ipcMain } = require("electron");
const { send: sendMainWindow } = require("./windows/main");
const {
create: createControlWindow,
send: sendControlWindow,
} = require("./windows/control");
const signal = require("./signal");
const robot = require("./robot");
module.exports = function () {
ipcMain.handle("login", async () => {
let { code } = await signal.invoke("login", null, "logined");
console.log("login--data", code);
return code;
});
ipcMain.on("control", async (e, remote) => {
signal.send("control", { remote });
});
signal.on("controlled", (data) => {
createControlWindow();
sendMainWindow("control-state-change", data.remote, 1);
});
signal.on("be-controlled", (data) => {
sendMainWindow("control-state-change", data.remote, 2);
});
ipcMain.on("forward", (e, event, data) => {
signal.send("forward", { event, data });
});
signal.on("offer", (data) => {
sendMainWindow("offer", data);
});
signal.on("answer", (data) => {
sendControlWindow("answer", data);
});
signal.on("puppet-candidate", (data) => {
sendControlWindow("candidate", data);
});
signal.on("control-candidate", (data) => {
sendMainWindow("candidate", data);
});
robot();
};
const { ipcMain } = require("electron");
const robot = require("robotjs");
const vkey = require("vkey");
function handleMouse(data) {
let { clientX, clientY, screen, video } = data;
let x = (clientX * screen.width) / video.width;
let y = (clientY * screen.height) / video.height;
console.log(x, y);
robot.moveMouse(x, y);
robot.mouseClick();
console.log("mouse", data);
}
function handleKey(data) {
const modifiers = [];
if (data.meta) modifiers.push("meta");
if (data.shift) modifiers.push("shift");
if (data.alt) modifiers.push("alt");
if (data.ctrl) modifiers.push("ctrl");
let key = vkey[data.keyCode].toLowerCase();
if (key[0] !== "<") {
robot.keyTap(key, modifiers);
}
console.log("key", data);
}
module.exports = function () {
ipcMain.on("robot", (e, type, data) => {
if (type === "mouse") {
handleMouse(data);
} else if (type === "key") {
handleKey(data);
}
});
};
const { BrowserWindow } = require("electron");
const isDev = require("electron-is-dev");
const path = require("path");
let win;
function create() {
win = new BrowserWindow({
width: 600,
height: 600,
webPreferences: {
nodeIntegration: true,
},
});
if (isDev) {
win.loadURL("http://localhost:3000");
} else {
win.loadFile(
path.resolve(__dirname, "../../renderer/pages/main/index.html")
);
}
}
function send(channel, ...args) {
win.webContents.send(channel, ...args);
}
module.exports = { create, send };
- 在react应用中,可以调用electron的能力与主进程通信:
import React, { useState, useEffect } from "react";
import "./controll";
const { ipcRenderer } = window.require("electron");
function App() {
const [remoteCode, setRemoteCode] = useState("");
const [localCode, setLocalCode] = useState("");
const [controlText, setControlText] = useState("");
const login = async () => {
let code = await ipcRenderer.invoke("login");
setLocalCode(code);
};
useEffect(() => {
login();
ipcRenderer.on("control-state-change", handleControlState);
return () => {
ipcRenderer.removeListener(
"control-state-change",
handleControlState
);
};
}, []);
const startControl = (remoteCode) => {
ipcRenderer.send("control", remoteCode);
};
const handleControlState = (e, name, type) => {
let text = "";
if (type === 1) {
text = `正在远程控制${name}`;
} else if (type === 2) {
text = `被${name}控制`;
}
setControlText(text);
};
return (
<div className="App">
{controlText === "" ? (
<>
<div>你的控制码{localCode}</div>
<input
type="text"
value={remoteCode}
onChange={(e) => setRemoteCode(e.target.value)}
/>
<button onClick={() => startControl(remoteCode)}>
确认
</button>
</>
) : (
<div>{controlText}</div>
)}
</div>
);
}
export default App;
- 当我们输入远端的随机码,即可触发control,也就是前面监听的control,这个control会发送输入的随机码给ws服务器,在ws服务器上,2个客户端的通信方法则被改写,ws会给2方发送被控制和已控制事件,监听到已控制事件的控制方会打开新窗口,并创建rtc链接,新窗口用来播放从被控制端获取的视频码流:
const { ipcRenderer } = require("electron");
const EventEmitter = require("events");
const peer = new EventEmitter();
const video = document.getElementById("screen-video");
function play(stream) {
video.srcObject = stream;
video.onloadedmetadata = () => video.play();
}
peer.on("add-stream", (stream) => {
play(stream);
});
window.onkeydown = function (e) {
console.log(e);
var data = {
keyCode: e.keyCode,
shift: e.shiftKey,
meta: e.metaKey,
control: e.controlKey,
alt: e.altKey,
};
peer.emit("robot", "key", data);
};
window.onmouseup = function (e) {
console.log(e);
var data = {
clientX: e.clientX,
clientY: e.clientY,
video: {
width: video.getBoundingClientRect().width,
height: video.getBoundingClientRect().height,
},
};
peer.emit("robot", "mouse", data);
};
peer.on("robot", (type, data) => {
if (type === "mouse") {
data.screen = {
width: window.screen.width,
height: window.screen.height,
};
}
setTimeout(() => {
ipcRenderer.send("robot", type, data);
}, 2000);
});
const pc = new window.RTCPeerConnection({});
async function createOffer() {
const offer = await pc.createOffer({
offerToReceiveAudio: false,
offerToReceiveVideo: true,
});
await pc.setLocalDescription(offer);
console.log("pc offer", JSON.stringify(offer));
return pc.localDescription;
}
createOffer().then((offer) => {
ipcRenderer.send("forward", "offer", { type: offer.type, sdp: offer.sdp });
});
async function setRemote(answer) {
await pc.setRemoteDescription(answer);
}
ipcRenderer.on("answer", (e, answer) => {
setRemote(answer);
});
pc.onaddstream = function (e) {
peer.emit("add-stream", e.stream);
};
pc.onicecandidate = function (e) {
if (e.candidate) {
ipcRenderer.send(
"forward",
"control-candidate",
JSON.stringify(e.candidate)
);
}
};
let candidates = [];
ipcRenderer.on("candidate", (e, candidate) => {
addIceCandidate(candidate);
});
async function addIceCandidate(candidate) {
if (candidate) {
candidates.push(candidate);
}
if (pc.remoteDescription && pc.remoteDescription.type) {
for (var i = 0; i < candidates.length; i++) {
await pc.addIceCandidate(
new RTCIceCandidate(JSON.parse(candidates[i]))
);
}
candidates = [];
}
}
- 监听到被控制方事件会触发control-state-change事件,改变文案显示,同时自身早已创建好监听,等待offer。
- 控制方会发送
ipcRenderer.send("forward", "offer", { type: offer.type, sdp: offer.sdp });
,用来再次转发给ws,将offer发给被控制方。
- 被控制方收到offer后会发送answer给控制方:
const { desktopCapturer, ipcRenderer } = window.require("electron");
function getScreenStream() {
return new Promise((resolve, reject) => {
desktopCapturer
.getSources({ types: ["window", "screen"] })
.then(async (sources) => {
for (const source of sources) {
try {
const stream = await navigator.mediaDevices.getUserMedia(
{
audio: false,
video: {
mandatory: {
chromeMediaSource: "desktop",
chromeMediaSourceId: source.id,
maxWidth: window.screen.width,
maxHeight: window.screen.height,
},
},
}
);
resolve(stream);
} catch (reject) {
console.error(reject);
}
}
});
});
}
const pc = new window.RTCPeerConnection({});
async function createAnswer(offer) {
let screenStream = await getScreenStream();
pc.addStream(screenStream);
await pc.setRemoteDescription(offer);
await pc.setLocalDescription(await pc.createAnswer());
return pc.localDescription;
}
ipcRenderer.on("offer", async (e, offer) => {
let answer = await createAnswer(offer);
ipcRenderer.send("forward", "answer", {
type: answer.type,
sdp: answer.sdp,
});
});
pc.onicecandidate = function (e) {
if (e.candidate) {
ipcRenderer.send(
"forward",
"puppet-candidate",
JSON.stringify(e.candidate)
);
}
};
let candidates = [];
async function addIceCandidate(candidate) {
if (candidate) {
candidates.push(candidate);
}
if (pc.remoteDescription && pc.remoteDescription.type) {
for (var i = 0; i < candidates.length; i++) {
await pc.addIceCandidate(new RTCIceCandidate(candidates[i]));
}
candidates = [];
}
}
ipcRenderer.on("candidate", (e, candidate) => {
addIceCandidate(candidate);
});
- 然后控制方会拿到answer用setRemoteDescription存起来。
- 这时2端的候选地址就会产生,一个发送
control-candidate
事件,一个发送puppet-candidate
事件。
- 然后2端通过
addIceCandidate
保存对方的候选地址。这样2端就通了。
- 前面控制端使用rtc监听
onaddstream
事件则被被控制端触发,收到视频码流,最后使用播放即可输出:
function play(stream) {
video.srcObject = stream;
video.onloadedmetadata = () => video.play();
}
peer.on("add-stream", (stream) => {
play(stream);
});
- 注意:robotjs的远端点击在win10下可能由于缩放问题不准,需要将win10的缩放调整至100%(一般的笔记本默认是125%甚至有150%的)。
- 通过rtc链接后可以使用datachannel来进行rtc级别的消息通信,但是我实测无法使用,暂时不知道哪里出了问题。有兴趣的可以看看mdn :https://developer.mozilla.org/zh-CN/docs/Web/API/RTCPeerConnection/createDataChannel
- 本篇代码地址:https://github.com/yehuozhili/learn-webrtc