【全栈】从0到1实现一个远程控制软件【Electron+Vue3+Vite+TS+Robotjs】项目介绍 【野百合远程
项目介绍
【野百合远程控制】是我的第 2 个全栈项目
前端部分:基于 Electron 从 0 到 1 实现了一个 PC 桌面端远程控制软件。 仓库:gitee.com/adamswan/Wi…
后端部分:采用 Nodejs 实现的 websocket 服务,以简单支撑前端的业务。 仓库:gitee.com/adamswan/Wi…
技术栈
前端部分:Electron、Vue3、Vite、TypeScript、Element Plus、Robotjs、Mitt、Electron-Vite CLI 等分:Electron、Vue3、Vite、TypeScript、Element Plus、Robotjs、Mitt、Electron-Vite CLI 等。安利Electron-Vite这个脚手架,比自己搭环境方便多了electron-vite.github.io/guide/getti…
后端部分:websocket服务、lodash等
野百合的使用
根据关系分为两个端,控制端,就是能控制对方电脑桌面的端;傀儡端,就是电脑桌面被控制的一方。
双方都使用同一个客户端,服务端会给每个客户端生成一个 6 位数字的控制码。
控制方输入控制码,即可打开另一个大窗口,实时显示傀儡端的电脑桌面。控制方使用自己的键鼠在大窗口内操作,傀儡端的键鼠将自动同步控制方的任何操作。
实现思路
1. 捕获傀儡端的桌面实时画面
Electron 提供的 desktopCapturer.getSources() API 能捕获傀儡端的桌面画面,并形成视频流
2. 获取傀儡端的视频流数据
浏览器提供的 navigator.mediaDevices.getDisplayMedia() API 能获取上一步得到的视频流
3. 传输傀儡端的视频流数据
使用 webRTC 技术的 P2P 技术能建立两个端之间的连接,并传输数据。
大致过程如下:
- 双方都 new 一个 RTCPeerConnection 对象,控制端发送一个 SDP 邀请给傀儡端,傀儡端接收到 SDP 邀请后,将其设置为自己的 remote 远端,然后再回应一个 SDP 应答,并将视频流塞进去。
- 控制端收到 SDP 应答,将其也设定为自己的 remote 远端,即双方互为彼此的远端,双向奔赴了。
- 至此,P2P 建立完成,视频传输完成。当然,这个过程还涉及了NAT穿透技术,web 做的主要事情就是使用 IceCandidate 获取双方的公网IP和端口号。
4. 控制端显示视频
事先准备一个空的 video 标签,监听 addstream 事件,将傀儡端的视频流塞入 video 标签的 srcObject 属性,即可在控制端大窗口内显示傀儡端的桌面。
5. 控制端操作傀儡端的键鼠
- 监听控制端的键鼠操作,将键鼠事件的数据通过 RTCPeerConnection 对象上的RTCDataChannel 对象,借助 P2P,将数据传给傀儡端。
- 傀儡端监听数据,通过 Electron 的 node 环境下的 robotjs 模块,将数据转成实际的键鼠操作,这样就实现了对傀儡端的键鼠控制。
前端代码
结构
controlPage.vue 启动页
<template>
<div class="container">
<img :src="lily" class="lily"></img>
<h3 v-show="isShow">您的控制码: {{ localCode }}</h3>
<h3 v-show="controlText.length !== 0">当前状态: {{ controlText }}</h3>
<el-divider v-show="isShow" />
<el-form v-show="isShow" ref="ruleFormRef" style="max-width: 600px" :model="ruleForm" :rules="rules"
label-width="auto" class="demo-ruleForm" size="large" status-icon>
<el-form-item label="控制码" prop="remoteCode">
<el-input v-model.number="ruleForm.remoteCode" placeholder="请输入对方控制码" />
</el-form-item>
<el-form-item class="btn-to-right">
<el-button type="primary" @click="submitForm(ruleFormRef)">
连接
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { ControlInfo, RuleForm } from '../Types/controlPage';
import lily from '../assets/lily.jpg?url'
const ruleFormRef = ref<FormInstance>()
const ruleForm = reactive<RuleForm>({
// 远程控制码
remoteCode: ''
})
// 本地控制码
const localCode = ref<number>(88888)
const setLocalCode = async (code: Promise<any>) => {
let res = await code
localCode.value = res
}
// 控制后的文字提示
const controlText = ref<string>('')
const setControlText = (status: string) => {
controlText.value = status
}
const isShow = computed(() => {
if (controlText.value.length === 0) {
return true
}
return false
})
const checkRemoteCode = (rule: any, value: any, callback: any) => {
if (!value) {
return callback(new Error('不能为空'))
}
if (Number.isInteger(value) === false) {
callback(new Error('只能为数字'))
}
if (value.toString().length !== 6) {
callback(new Error('长度只能6位'))
}
callback()
}
const rules = reactive<FormRules<RuleForm>>({
remoteCode: [{ validator: checkRemoteCode, trigger: 'change' }],
})
// 开始控制
const startControl = (code: number | string) => {
// 触发预加载脚本的自定义函数
(window as any).myAPI.startControl(code)
}
const submitForm = async (formEl: FormInstance | undefined) => {
if (!formEl) return
await formEl.validate((valid, fields) => {
if (valid) {
startControl(ruleForm.remoteCode)
} else {
console.log('error submit!', fields)
}
})
}
const login = async () => {
// 让主进程去登录,获取本地状态码
const code = (window as any).myAPI.doLogin()
// 设置本地状态码
setLocalCode(code)
}
const wiredFun = () => {
// 监听傀儡端被控制
(window as any).myAPI.pupeIsControled()
.then((remote: number) => {
setControlText(`被${remote}远程控制中...`)
})
}
// 监听主进程发的控制消息
(window as any).myAPI.controlStateChange().then((obj: ControlInfo) => {
const { type, name } = obj
if (type === 1) { // 在控制别人
setControlText(`正在远程控制${name}`)
}
})
onMounted(() => {
login()
wiredFun()
})
</script>
<style scoped lang="less">
.container {
overflow: hidden;
height: 100%;
padding-top: 30px;
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
background-color: rgba(244, 244, 244);
.btn-to-right {
:deep(.el-form-item__content) {
justify-content: end;
}
}
.lily {
width: 200px;
height: 200px;
}
}
</style>
启动窗口,启动页的html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>野百合远程控制</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
第二窗口,显示视频的html
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>野百合远程控制</title>
<style>
* {
margin: 0;
}
#screen-video {
width: 100%;
height: 100%;
object-fit: fill;
}
</style>
</head>
<body>
<video id="screen-video"></video>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
main.ts 主进程
import { app, BrowserWindow, ipcMain, desktopCapturer, session } from 'electron'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import path from 'node:path'
import { getDeskRealTimeVideoStream } from './getRealTime.ts'
import './robotToControlUser.ts'
import { autoLogin, sendDataToControl, listenToBeControl, forwardInfo } from './websocket.ts'
import { NumOrStr } from './lily'
const require = createRequire(import.meta.url)
const __dirname = path.dirname(fileURLToPath(import.meta.url))
// The built directory structure
//
// ├─┬─┬ dist
// │ │ └── index.html
// │ │
// │ ├─┬ dist-electron
// │ │ ├── main.js
// │ │ └── preload.mjs
// │
process.env.APP_ROOT = path.join(__dirname, '..')
// 🚧 Use ['ENV_NAME'] avoid vite:define plugin - Vite@2.x
export const VITE_DEV_SERVER_URL = process.env['VITE_DEV_SERVER_URL']
export const MAIN_DIST = path.join(process.env.APP_ROOT, 'dist-electron')
export const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist')
process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL ? path.join(process.env.APP_ROOT, 'public') : RENDERER_DIST
// 初始窗口
let win: BrowserWindow | null
// 用于 webRTC 的新窗口
let newWin: BrowserWindow
function createWindow() {
win = new BrowserWindow({
width: 410,
height: 530,
autoHideMenuBar: true,
resizable: false,
icon: path.join(process.env.VITE_PUBLIC, 'electron-vite.svg'),
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
},
})
// Test active push message to Renderer-process.
win.webContents.on('did-finish-load', () => {
win?.webContents.send('main-process-message', (new Date).toLocaleString())
})
if (VITE_DEV_SERVER_URL) {
win.loadURL(VITE_DEV_SERVER_URL)
// win.webContents.openDevTools() //自动打开控制台
} else {
// win.loadFile('dist/index.html')
win.loadFile(path.join(RENDERER_DIST, 'index.html'))
}
// 监听窗口关闭事件
win.on('close', () => {
// 初始窗口也一并关闭
newWin?.close()
app.quit()
});
}
function createNEWWindow() {
newWin = new BrowserWindow({
width: 1500,
height: 888,
autoHideMenuBar: true,
x: 0,
y: 0,
webPreferences: {
preload: path.join(__dirname, 'preload.mjs'),
nodeIntegration: true,
webSecurity: false,
// contextIsolation: false,
}
})
}
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') {
app.quit()
win = null
}
})
app.on('activate', () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
createWindow()
}
})
app.whenReady().then(createWindow)
// 1、处理登录
handleLogin()
function handleLogin() {
ipcMain.handle('login', async () => {
// 先返回一个假数据
const { code } = await autoLogin('login', null) as any
return code
})
}
// 2、监听控制端发起的控制行为
linstenToControl()
function linstenToControl() {
ipcMain.on('control', async (event, code) => {
await sendDataToControl('control', { 'remote': code })
controlSuccess(1, code)
})
}
// 3、当控制成功时
async function controlSuccess(type: number, name: number) {
// 通知渲染进程控制成功了
win?.webContents.send('controlStateChange', { type, name })
// 新建窗口
createNEWWindow()
if (newWin) {
// 捕获傀儡端的实时视频流
getDeskRealTimeVideoStream(desktopCapturer, session)
//! 坑: loadFile 方法通常用于加载本地文件系统中的 HTML 文件,而不是从开发服务器(如 Vite 开发服务器)加载。如果你的 HTML 文件是通过 Vite 打包或服务的,你应该使用 loadURL 方法并指向 Vite 开发服务器的 URL
newWin.loadURL('http://localhost:5173/new-win-controled.html');
// newWin.webContents.openDevTools(); // 自动打开F12
// 监听窗口关闭事件
newWin.on('close', () => {
// 初始窗口也一并关闭
win?.close()
app.quit()
win = null
});
}
}
// 4、告知傀儡端,它被控制了
tellPupeIsControled()
async function tellPupeIsControled() {
const res: any = await listenToBeControl() // 开启监听
win?.webContents.send('pupeIsControled', res.remote)
}
// 5、监听转发事件
listenForward()
function listenForward() {
ipcMain.on('forward', (e, type, oData) => {
forwardInfo(type, oData)
})
}
// 小窗
export function mainToRender(channel: string, data: string) {
win?.webContents.send(channel, data)
}
// 大窗
export function mainBigWinToRender(channel: string, data: string) {
newWin?.webContents.send(channel, data)
}
getRealTime.ts 捕获傀儡端视频流
export const getDeskRealTimeVideoStream = (desktopCapturer: any, session: any) => {
session.defaultSession.setDisplayMediaRequestHandler((request: any, callback: any) => {
// 捕获电脑桌面
desktopCapturer.getSources({ types: ['screen'] })
.then((sources: any) => {
// Grant access to the first screen found.
callback({ video: sources[0], audio: 'loopback' })
})
})
}
websocket.ts 和服务端通信
import WebSocket from 'ws';
import mitt from 'mitt' // 发布订阅
import { mainBigWinToRender, mainToRender } from './main'
import { ipcMain } from 'electron';
import { Mitter, MsgObj } from './lily';
const ws: WebSocket = new WebSocket('ws://127.0.0.1:8088')
export const mitter = mitt() as Mitter
// 监听连接成功的事件
ws.on('open', () => {
console.log('connect success')
})
// 监听 message 事件
ws.on('message', (message: string) => {
let res = {} as MsgObj
try {
res = JSON.parse(message)
} catch (err) {
console.log('err', err)
}
// 向外抛出数据
mitter.emit(res.action, res.data)
})
// 发数据
export function sendDataWithJSON(type: string, oData: object | null) {
let sendData = {
action: type
} as MsgObj
if (oData !== null) {
sendData.data = oData
}
ws.send(JSON.stringify(sendData))
}
// 自动登录
export function autoLogin(type: string, oData: object | null) {
return new Promise((resolve) => {
sendDataWithJSON(type, oData)
mitter.on('login-success', (data: object) => {
resolve(data)
})
})
}
// 控制端发消息 要求控制对象
export function sendDataToControl(type: string, oData: object | null) {
return new Promise((resolve) => {
sendDataWithJSON(type, oData)
mitter.on('control-success', (data: object) => {
resolve(data)
})
})
}
// 傀儡端监听被控制的事件,切换页面文字
export function listenToBeControl() {
return new Promise((resolve) => {
mitter.on('controlled-by', (data: object) => {
resolve(data)
})
})
}
// 转发消息
export function forwardInfo(type: string, oData: object) {
return new Promise((resolve) => {
sendDataWithJSON(type, oData)
// 监听 forward 事件
mitter.on('forward', (data: object) => {
resolve(data)
})
})
}
ipcMain.on('pcOfferSendToWS', (e, offer) => {
let oData = {
action: 'pcoffer',
data: {
pcoffer: offer
}
}
// 发出去
ws.send(JSON.stringify(oData))
// 监听回来的数据
mitter.on('pcoffer-for-createAnswer', (data: MsgObj) => {
// 主进程 -> 渲染进程
mainToRender('gen-answer', data.res)
})
})
ipcMain.on('send-answer', (e, answer) => {
let oData = {
action: 'answer',
data: {
answer: answer
}
}
// 发出去
ws.send(JSON.stringify(oData))
mitter.on('answer-for-set-remote', (data: MsgObj) => {
// 主进程 -> 渲染进程
mainBigWinToRender('set-remote', data.res)
})
})
ipcMain.on('send-candidate-to-small-win', (e, candidate) => {
let oData = {
action: 'candidate',
data: {
candidate: candidate
}
}
// 发出去
ws.send(JSON.stringify(oData))
mitter.on('for-pupe-addIce', (data: any) => {
// 主进程 -> 渲染进程
mainToRender('set-addIce', data.res)
})
})
robotToControlUser.ts 将键鼠事件转换为真实操作
import { ipcMain } from "electron"
import { createRequire } from 'node:module'
import { Keyboard, MouseupData } from "./lily"
const require = createRequire(import.meta.url)
const robot = require('robotjs')
const vkey = require('vkey')
// 控制傀儡端的鼠标操作
export const handleMouse = (data: MouseupData) => {
let { clientX, clientY, screen, video } = data
// 根据比例关系,转换为在傀儡端桌面真实的鼠标坐标
let x = clientX * screen.width / video.width
let y = clientY * screen.height / video.height
// 移动鼠标到坐标位置
robot.moveMouse(x, y)
// 点击鼠标
robot.mouseClick()
}
// 控制傀儡端的键盘操作
export const handleKey = (data: Keyboard) => {
// 处理组合键
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)
}
}
ipcMain.on('autoOperateMouse', (e, data) => {
handleMouse(data)
})
ipcMain.on('autoOperateKeyboard', (e, data) => {
console.log('autoOperateKeyboard', data)
handleKey(data)
})
preload.ts 渲染进程
import { ipcRenderer, contextBridge } from 'electron'
import { listenToKey, listentoMouse } from './peer-control'
// --------- Expose some API to the Renderer process ---------
// 在这里向 window 上添加自定义的属性、方法
contextBridge.exposeInMainWorld('ipcRenderer', {
on(...args: Parameters<typeof ipcRenderer.on>) {
const [channel, listener] = args
return ipcRenderer.on(channel, (event, ...args) => listener(event, ...args))
},
off(...args: Parameters<typeof ipcRenderer.off>) {
const [channel, ...omit] = args
return ipcRenderer.off(channel, ...omit)
},
send(...args: Parameters<typeof ipcRenderer.send>) {
const [channel, ...omit] = args
return ipcRenderer.send(channel, ...omit)
},
invoke(...args: Parameters<typeof ipcRenderer.invoke>) {
const [channel, ...omit] = args
return ipcRenderer.invoke(channel, ...omit)
},
// You can expose other APTs you need here.
// ...
})
const myAPI = {
// 登录
doLogin: async () => {
let res = await ipcRenderer.invoke('login')
return res
},
// 控制状态发生变化
controlStateChange: () => {
return new Promise((resolve) => {
ipcRenderer.on('controlStateChange', (event, data) => {
resolve(data)
})
})
},
// 渲染进程向主进程单向通信,告知主进程,控制开始
startControl: (code: string) => {
ipcRenderer.send('control', code)
},
pupeIsControled: (event: any, remote: number) => {
return new Promise((resolve) => {
ipcRenderer.on('pupeIsControled', (event, remote) => {
resolve(remote)
})
})
}
}
contextBridge.exposeInMainWorld('myAPI', myAPI)
if (document.getElementById('screen-video')) {
listenToKey() // 监听控制端键盘
listentoMouse()// 监听控制端鼠标
}
peer-control.ts 建立P2P连接并传输视频与键鼠事件数据
import { ipcRenderer } from 'electron'
import mitte from 'mitt'
import { MouseupData } from './lily'
const mitter2 = mitte() // 发布订阅
const video: HTMLVideoElement = document.getElementById('screen-video') as HTMLVideoElement
export const getVideoStream = () => {
return new Promise((resolve) => {
navigator.mediaDevices.getDisplayMedia({
audio: false,
video: {
width: { max: window.screen.width },
height: { max: window.screen.height }
}
})
.then(stream => {
// 将视频流传出去
resolve(stream)
})
.catch(e => console.log(e))
})
}
// 1、创建控制端的 SDP 邀请
const pc: any = new window.RTCPeerConnection({})
// 创建控制端的 RTCDataChannel
const dataChannel: RTCDataChannel = pc.createDataChannel('robotchannel', { reliable: false })
dataChannel.onopen = () => {// 监听数据通道打开
// 监听鼠标事件
mitter2.on('mouseup', (obj) => {
// 通过 dataChannel 发给傀儡端
dataChannel.send(JSON.stringify(obj))
})
// 监听键盘事件
mitter2.on('keydown', (obj) => {
// 通过 dataChannel 发给傀儡端
dataChannel.send(JSON.stringify(obj))
})
}
dataChannel.onmessage = (event: MessageEvent<any>) => {
console.log('dataChannel onmessage', event)
}
dataChannel.onerror = (event: Event) => {
console.log('dataChannel onerror', event)
}
// webRTC NAT穿透:ICE, 交互式连接创建
let bFlag = true
pc.onicecandidate = function (e: RTCPeerConnectionIceEvent) {
if (e.candidate && bFlag === true) {
console.log('candidate', video, JSON.stringify(e.candidate))
ipcRenderer.send('send-candidate-to-small-win', JSON.stringify(e.candidate))
bFlag = false
}
}
let candidateForControl: RTCIceCandidateInit[] = []
export async function addIceCandidateForControl(candidate: RTCIceCandidateInit) {
if (candidate) {
candidateForControl.push(candidate)
}
if (pc.remoteDescription && pc.remoteDescription.type) {
for (let i = 0; i < candidateForControl.length; i++) {
await pc.addIceCandidate(new RTCIceCandidate(candidateForControl[i]))
}
candidateForControl = []
}
}
// 2、将 SDP 邀请发出去
const createOffer = async () => {
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(async (offer) => {
if (video !== null) { // 大窗口
ipcRenderer.send('pcOfferSendToWS', JSON.stringify(offer))
}
})
// 监听 addstream 事件,显示视频
pc.onaddstream = (e: any) => {
console.log('add-stream', e.stream)
// 给 video 标签设定视频流地址
video.srcObject = e.stream
// 元数据加载完成后播放
video.onloadedmetadata = (e) => video.play()
}
// 傀儡端
const pc2: any = new window.RTCPeerConnection({})
// 监听通道
pc2.ondatachannel = (e: any) => {
// 监听datachannel消息
e.channel.onmessage = (event: any) => {
let { type, widthAndHeight, data } = JSON.parse(event.data)
if (type === 'mouse') { // 将数据传给robotjs, 让它自动控制鼠标
data.screen = {
width: widthAndHeight.windowWidth,
height: widthAndHeight.windowHeight
}
ipcRenderer.send('autoOperateMouse', data)
} else if (type === 'keyboard') {
ipcRenderer.send('autoOperateKeyboard', data)
}
}
}
pc2.onicecandidate = function (e: any) {// webRTC NAT穿透:ICE, 交互式连接创建
if (e.candidate) {
ipcRenderer.send('forward', 'puppet-candidate', JSON.stringify(e.candidate))
}
}
let count = 0
ipcRenderer.on('set-addIce', (e, candidate) => {
if (!video) { // 小窗
count++
if (count === 3) {
setTimeout(() => {
addIceCandidateForPupe(JSON.parse(candidate))
}, 1000)
}
}
})
let candidateForPupe: any = []
export async function addIceCandidateForPupe(candidate: any) {
if (candidate) {
candidateForPupe.push(candidate)
}
// if (pc2.remoteDescription && pc2.remoteDescription.type) {
for (let i = 0; i < candidateForPupe.length; i++) {
await pc2.addIceCandidate(new RTCIceCandidate(candidateForPupe[i]))
}
candidateForPupe = []
// }
}
// 3、傀儡端收到 SDP 邀请,做 3 件事:
// 1) 将 SDP 设置为自己的 remote
// 2) 也创建一个 RTCPeerConnection 连接,并将视频流放进去
// 3)向控制端发送一个 SDP 应答
ipcRenderer.on('gen-answer', async (e, offer: string) => {
if (!document.getElementById('screen-video')) {// 小窗
let answer = await createAnswer(JSON.parse(offer))
ipcRenderer.send('send-answer', JSON.stringify(answer))
}
})
export async function createAnswer(offer: string) {
let screenStream = await getVideoStream() // 获取视频流
pc2.addStream(screenStream)
await pc2.setRemoteDescription(offer)
await pc2.setLocalDescription(await pc2.createAnswer())
return pc2.localDescription
}
// 将傀儡端响应的 SDP 应答设置为控制端的 remote
export const setRemote = async (answer: string) => {
await pc.setRemoteDescription(answer)
}
ipcRenderer.on('set-remote', (event, answer) => {
if (video) {
setRemote(JSON.parse(answer))
}
})
// 监听控制端的键盘
export const listenToKey = () => {
window.addEventListener('keydown', (e) => {
let data = {
keyCode: e.keyCode,
shift: e.shiftKey,
meta: e.metaKey,
control: e.ctrlKey,
alt: e.altKey
}
// 抛出键盘事件
mitter2.emit('keydown', {
type: "keyboard",
data
})
})
}
// 监听控制端的鼠标
export const listentoMouse = () => {
window.addEventListener('mouseup', (e) => {
let data: Partial<MouseupData> = {}
data.clientX = e.clientX
data.clientY = e.clientY
data.video = { // 获取视频区域的真实宽高
width: video.getBoundingClientRect().width,
height: video.getBoundingClientRect().height
}
// 抛出鼠标事件
mitter2.emit('mouseup', {
type: 'mouse',
widthAndHeight: {
windowWidth: window.screen.width,
windowHeight: window.screen.height
},
data
})
})
}
后端代码
只有一个文件 websocketServer.js
const WebSocket = require('ws')
const { random } = require('lodash')
// 创建 WebSocket 服务
const wss = new WebSocket.Server({
port: 8088
})
// 建立 6 位随机数与 ws 实例的映射
// 客户端、傀儡端都会登录,所以会产生两个 Map 成员
const map = new Map()
wss.on('connection', function connection(ws, request) { // 监听 connection 事件
// 新增方法:发送格式化的数据
ws.sendDataWithJSON = (type, oData) => {
ws.send(JSON.stringify({
action: type,
data: oData
}))
}
// 新增方法:处理错误请求数据
ws.sendError = (oData) => {
ws.send(JSON.stringify({
action: 'error',
data: oData
}))
}
// 生成 6 位随机数
const randomNum = random(100000, 999999, false)
console.log('6 位随机数', randomNum)
map.set(randomNum, ws)
// 监听 message 事件
listenMsg(randomNum, ws)
// 监听 close 事件
listenClose(randomNum, ws)
// 超时自动断开
whenTimeout(ws)
})
let pcofferTemp
let answerTemp
let candidateTemp
function listenMsg(randomNum, instance) {
instance.on('message', function (message) {
// console.log('服务端收到消息:', message.toString('utf8'))
let parsedMessage = {
// action: '操作类型',
// data: '负载'
}
try {
parsedMessage = JSON.parse(message.toString('utf8'))
} catch (errInfo) {
instance.sendError(errInfo)
return console.log('无效消息:', errInfo)
}
const { action, data } = parsedMessage
if (action === 'login') { //! 处理登录
instance.sendDataWithJSON('login-success', { code: randomNum })
} else if (action === 'control') { //! 处理控制
// 控制端输入傀儡端的 code,作为 remote 字段的值发送给ws服务,
// 表示要控制code值为该值的傀儡端
const remote = Number(data.remote)
if (map.has(remote)) { // 如果要控制的用户存在
// 通知控制端,控制成功
instance.sendDataWithJSON('control-success', { 'remote': remote })
//! 建立两个ws实例的联动关系
// 获取傀儡端的 ws 实例
let remoteWS = map.get(remote)
// 将傀儡端的 ws 实例的 sendDataWithJSON 方法设置为控制端的 sendRemote 方法
instance.sendRemote = remoteWS.sendDataWithJSON
// 将控制端的 ws 实例的 sendDataWithJSON 方法设置为傀儡端的 sendRemote 方法
remoteWS.sendRemote = instance.sendDataWithJSON
// 通知傀儡端,它被控制了
instance.sendRemote('controlled-by', { 'remote': randomNum })
} else {
instance.sendError('用户不存在')
}
} else if (action === 'forward') { //! 处理转发
console.error('data.event', data.event)
instance.sendRemote(data.event, data.data)
} else if (action === 'pcoffer') {
console.error('pcoffer', data.pcoffer)
pcofferTemp = data.pcoffer
instance.sendDataWithJSON('pcoffer-for-createAnswer', { res: pcofferTemp })
} else if (action === 'answer') {
console.error('answer', data.answer)
answerTemp = data.answer
instance.sendDataWithJSON('answer-for-set-remote', { res: answerTemp })
} else if (action === 'candidate') {
console.log('candidate', data.candidate)
candidateTemp = data.candidate
instance.sendDataWithJSON('for-pupe-addIce', { res: candidateTemp })
}
})
}
function listenClose(randomNum, instance) {
instance.on('close', () => {
pcofferTemp = ''
answerTemp = ''
candidateTemp = ''
map.delete(randomNum)
delete instance.sendRemote
clearTimeout(instance._closeTimeout)
})
}
function whenTimeout(instance) {
instance._closeTimeout = setTimeout(() => {
instance.terminate()
}, 600000);
}
转载自:https://juejin.cn/post/7413356439474995215