前言

  • 上一次进行了手动交换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) {
	//ws端
	//随机码  六位数
	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); //传过来的数据类型是:{event,data}
		let parsedMessage = {};
		try {
			//通过event判断类型,交换到两端不同的函数里去
			//防止服务器会崩溃
			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)) {
				//相当于把这个客户端的发送方法与另一个客户端发送方法统一起来。
				// 远端发送方法都为sendRemote,同时发送被控制和已控制事件
				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) {
	//传过来的数据:data:{clientX,clientY,screen:{width,height},video:{width,height}}
	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) {
	//传过来的数据:data:{keyCode,meta,alt,ctrl,shift}
	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] !== "<") {
		//排除<shift>特殊字符
		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);
		}
	});
};
  • 启动应用则是创建了一个react应用:
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: {
			//可以使用node相关的
			nodeIntegration: true,
		},
	});
	if (isDev) {
		win.loadURL("http://localhost:3000");
	} else {
		win.loadFile(
			//这里是react打包后的目录
			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"; //使用hooks
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
Logo

基于 Vue 的企业级 UI 组件库和中后台系统解决方案,为数万开发者服务。

更多推荐